On the wire · 16:01:29 UTC Live 126 · Prematch 8,738 · Markets 36,347 Vol. I · No. 1 · Monday 9 November 2026 wss://pinnfeed.io/v1/stream
PinnFeed
Guide · Python · Node

Les cotes Pinnacle en temps réel par WebSocket avec Python et Node

Mis à jour le · PinnFeed · 6 min de lecture

Dans ce guide, vous construisez un client qui garde en mémoire une copie à jour des prix Pinnacle : il se connecte, s'abonne, charge le snapshot, applique chaque delta, répond aux battements de cœur et se reconnecte tout seul. Les autres guides (alertes de steam, value betting, le bot Telegram) s'appuient sur lui.

Il vous faut une clé PinnFeed sur le plan Builder (le plan WebSocket, 99 €/mois, 3 jours gratuits). Avec une clé Free ou Hobby, vous pouvez suivre les exemples REST de la documentation (en anglais).

Étape 1 — Obtenez une clé et placez-la dans l'environnement#

Créez une clé dans le tableau de bord et exportez-la, pour qu'elle ne se retrouve jamais dans votre code :

export PINNFEED_KEY="ps_live_..."

Étape 2 — Comprenez les frames#

Après la connexion et l'envoi de subscribe, le serveur envoie :

  1. subscribed, puis un snapshot découpé (part, parts, final) avec tous les événements et marchés qui correspondent à vos filtres ;
  2. des frames delta, chacune avec une liste de changes : upsert apporte le nouvel objet complet et remove seulement son id ;
  3. un ping toutes les 20 secondes. Répondez par {"type":"pong"}, sinon le serveur ferme le socket après 60 secondes de silence.

Les marchés sont identifiés par id (eventId:key), un dictionnaire suffit donc comme état. Chaque issue porte decimal, american, fair (cote juste sans marge) et fairProb. La référence du WebSocket (en anglais) liste tous les champs.

Étape 3 — Le client Python#

Installez l'unique dépendance avec pip install websockets (Python 3.9+). Enregistrez-le sous pinnfeed_stream.py ; les guides steam et Telegram l'importent.

# pinnfeed_stream.py : garde une copie à jour du carnet Pinnacle depuis le WebSocket PinnFeed.
# pip install websockets   (Python 3.9+)
import asyncio, json, os, random
import websockets

WS_URL = os.environ.get("PINNFEED_WS", "wss://pinnfeed.io/v1/stream")
KEY = os.environ["PINNFEED_KEY"]
SIDES = {"home", "away", "draw", "over", "under"}


class Book:
    """Événements et marchés par id, mis à jour à partir des frames snapshot + delta."""

    def __init__(self):
        self.events, self.markets = {}, {}

    def apply(self, msg, on_market=None):
        if msg["type"] == "snapshot":
            for e in msg["events"]:
                self.events[e["id"]] = e
            for m in msg["markets"]:
                self.markets[m["id"]] = m
        elif msg["type"] == "delta":
            for c in msg["changes"]:
                if c["kind"] == "event" and c["op"] == "upsert":
                    self.events[c["event"]["id"]] = c["event"]
                elif c["kind"] == "event" and c["op"] == "remove":
                    self.events.pop(c["id"], None)
                    for mid in [k for k, m in self.markets.items() if m["eventId"] == c["id"]]:
                        del self.markets[mid]
                elif c["kind"] == "market" and c["op"] == "upsert":
                    old = self.markets.get(c["market"]["id"])
                    self.markets[c["market"]["id"]] = c["market"]
                    if on_market:
                        on_market(old, c["market"], self.events.get(c["market"]["eventId"]))
                elif c["kind"] == "market" and c["op"] == "remove":
                    self.markets.pop(c["id"], None)


async def stream(subscription, on_market=None, on_ready=None):
    """Se connecte, s'abonne, tient le carnet à jour ; se reconnecte avec backoff indéfiniment."""
    delay = 1
    while True:
        book = Book()
        try:
            async with websockets.connect(WS_URL, additional_headers={"x-api-key": KEY}, max_size=None) as ws:
                await ws.send(json.dumps({"type": "subscribe", **subscription}))
                async for raw in ws:
                    msg = json.loads(raw)
                    if msg["type"] == "ping":
                        await ws.send('{"type":"pong"}')
                    elif msg["type"] == "error":
                        print("server error:", msg["code"], msg["message"])
                    elif msg["type"] in ("snapshot", "delta"):
                        book.apply(msg, on_market if msg["type"] == "delta" else None)
                        if msg["type"] == "snapshot" and msg.get("final") and on_ready:
                            on_ready(book)
                        delay = 1
        except (OSError, websockets.ConnectionClosed) as e:
            print(f"disconnected ({e}); reconnecting in {delay}s")
        await asyncio.sleep(delay + random.random())
        delay = min(delay * 2, 30)


if __name__ == "__main__":
    def ready(book):
        print(f"book ready: {len(book.events)} events, {len(book.markets)} markets")

    def moved(old, new, event):
        if old and event:
            for o_old, o_new in zip(old["outcomes"], new["outcomes"]):
                if o_new["name"] in SIDES and o_old["decimal"] != o_new["decimal"]:
                    print(f'{event["home"]} v {event["away"]} {new["type"]} {o_new["name"]}: '
                          f'{o_old["decimal"]} -> {o_new["decimal"]} (fair {o_new["fair"]})')

    asyncio.run(stream({"sports": ["soccer"], "markets": ["moneyline"], "main": True, "specials": False}, moved, ready))

Lancez-le avec python pinnfeed_stream.py. Vous verrez book ready: N events, M markets, puis une ligne par mouvement de prix.

Étape 4 — Le client Node#

La même logique en Node 18+ avec npm install ws. Enregistrez-le sous stream.mjs et lancez node stream.mjs.

// stream.mjs : carnet Pinnacle en direct depuis le WebSocket PinnFeed (Node 18+).
// npm install ws
import WebSocket from 'ws';

const WS_URL = process.env.PINNFEED_WS || 'wss://pinnfeed.io/v1/stream';
const KEY = process.env.PINNFEED_KEY;
const SIDES = new Set(['home', 'away', 'draw', 'over', 'under']);
const events = new Map();
const markets = new Map();

function connect(delay = 1000) {
  const ws = new WebSocket(WS_URL, { headers: { 'x-api-key': KEY }, perMessageDeflate: true });
  ws.on('open', () => {
    delay = 1000;
    ws.send(JSON.stringify({ type: 'subscribe', sports: ['soccer', 'tennis'], markets: ['moneyline'], main: true, specials: false }));
  });
  ws.on('message', (raw) => {
    const msg = JSON.parse(raw);
    if (msg.type === 'ping') return ws.send('{"type":"pong"}');
    if (msg.type === 'error') return console.error('server error:', msg.code, msg.message);
    if (msg.type === 'snapshot') {
      if (msg.part === 1) { events.clear(); markets.clear(); }
      msg.events.forEach((e) => events.set(e.id, e));
      msg.markets.forEach((m) => markets.set(m.id, m));
      if (msg.final) console.log(`book ready: ${events.size} events, ${markets.size} markets`);
    }
    if (msg.type === 'delta') {
      for (const c of msg.changes) {
        if (c.kind === 'event') c.op === 'upsert' ? events.set(c.event.id, c.event) : events.delete(c.id);
        if (c.kind === 'market' && c.op === 'remove') markets.delete(c.id);
        if (c.kind === 'market' && c.op === 'upsert') {
          const old = markets.get(c.market.id);
          markets.set(c.market.id, c.market);
          const ev = events.get(c.market.eventId);
          if (!old || !ev) continue;
          c.market.outcomes.forEach((o, i) => {
            const prev = old.outcomes[i];
            if (prev && SIDES.has(o.name) && prev.decimal !== o.decimal) console.log(`${ev.home} v ${ev.away} ${o.name}: ${prev.decimal} -> ${o.decimal} (fair ${o.fair})`);
          });
        }
      }
    }
  });
  ws.on('close', (code) => {
    const wait = Math.min(delay * 2, 30000);
    console.log(`closed (${code}); reconnecting in ${wait / 1000}s`);
    setTimeout(() => connect(wait), wait + Math.random() * 1000);
  });
  ws.on('error', (e) => console.error('ws error:', e.message));
}
connect();

Étape 5 — Restreignez l'abonnement#

Les filtres s'appliquent côté serveur : vous ne recevez et ne traitez que ce que vous demandez.

{ "type": "subscribe", "sports": ["tennis"], "live": true, "markets": ["moneyline", "total"], "main": true }
  • sports : slugs ou ids ; leagues et events : listes d'ids.
  • live : true en direct uniquement, false avant-match uniquement ; omis, les deux.
  • main: true ne garde que le vainqueur, le handicap et le total principaux de chaque événement.
  • Renvoyer subscribe remplace l'abonnement et déclenche un nouveau snapshot.

Liste de contrôle pour la production#

  • Reconnectez-vous avec backoff et jitter (les deux clients le font : 1 s, 2 s, 4 s, jusqu'à 30 s) et reconstruisez à partir du nouveau snapshot.
  • Activez la compression : websockets en Python négocie permessage-deflate par défaut ; en Node, passez perMessageDeflate: true. Les snapshots sont réduits de 5 à 10 fois.
  • Lisez vite. Si plus de 16 Mo s'accumulent pour un client lent, le serveur le ferme avec le code 4008. Faites le travail lourd en dehors de la boucle du socket.
  • Utilisez seq pour les logs, pas pour détecter des trous : les filtres sautent les lots auxquels vous n'êtes pas abonné.

Questions fréquentes#

Quel plan faut-il pour le WebSocket ?#

Builder (99 €/mois) ou supérieur. Les clés Free et Hobby reçoivent 403 plan_lacks_ws sur le WebSocket et peuvent utiliser le REST.

Combien de connexions puis-je ouvrir ?#

Builder permet 3 connexions WebSocket par compte, Pro en permet 10.

Pourquoi certaines issues ont-elles des ids de participant au lieu de home ou away ?#

Les spéciaux et certains paris à long terme listent des participants plutôt qu'une équipe à domicile et à l'extérieur. Abonnez-vous avec "specials": false et filtrez sur home, away, draw, over et under si vous ne voulez que les lignes de match, comme le font les exemples.

Le plan WebSocket : Builder, 99 €/mois.

Les cotes Pinnacle en temps réel sur un seul WebSocket : les 13 sports, tous les marchés, cotes justes sans marge et cotes de clôture. 3 jours gratuits, résiliable en un clic.

Form K-1 · Free API key

Get your free API key.

No password. We email you a link; click it and your key is on screen.

One free key per email. Link valid for 20 minutes.