Les cotes Pinnacle en temps réel par WebSocket avec Python et Node
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 :
subscribed, puis un snapshot découpé (part,parts,final) avec tous les événements et marchés qui correspondent à vos filtres ;- des frames delta, chacune avec une liste de
changes:upsertapporte le nouvel objet complet etremoveseulement son id ; - 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 ;leaguesetevents: listes d'ids.live:trueen direct uniquement,falseavant-match uniquement ; omis, les deux.main: truene garde que le vainqueur, le handicap et le total principaux de chaque événement.- Renvoyer
subscriberemplace 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 :
websocketsen Python négociepermessage-deflatepar défaut ; en Node, passezperMessageDeflate: 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
seqpour 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.