Pinnacle-Quoten in Echtzeit per WebSocket mit Python und Node
In dieser Anleitung baust du einen Client, der eine Live-Kopie der Pinnacle-Preise im Speicher hält: Er verbindet sich, abonniert, lädt den Snapshot, wendet jedes Delta an, beantwortet Heartbeats und verbindet sich selbst neu. Die anderen Anleitungen (Steam-Alerts, Value Betting, der Telegram-Bot) bauen darauf auf.
Du brauchst einen PinnFeed-Key im Builder-Plan (der WebSocket-Plan, 99 €/Monat, 3 Tage kostenlos). Mit einem Free- oder Hobby-Key kannst du die REST-Beispiele aus der Dokumentation (auf Englisch) nutzen.
Schritt 1 — Key holen und in der Umgebung ablegen#
Leg im Dashboard einen Key an und exportiere ihn, damit er nie in deinem Code landet:
export PINNFEED_KEY="pf_live_..."
Schritt 2 — Die Frames verstehen#
Nach dem Verbinden und dem Senden von subscribe schickt der Server:
subscribedund danach einen Snapshot in Teilen (part,parts,final) mit allen Events und Märkten, die zu deinen Filtern passen;- Delta-Frames, jeder mit einer Liste
changes:upsertenthält das vollständige neue Objekt,removenur dessen id; - alle 20 Sekunden einen Ping. Antworte mit
{"type":"pong"}, sonst schließt der Server den Socket nach 60 Sekunden Stille.
Märkte werden über id (eventId:key) identifiziert, ein Dictionary ist also der gesamte Zustand, den du brauchst. Jeder Ausgang hat decimal, american, fair (faire Quote ohne Marge) und fairProb. Die WebSocket-Referenz (auf Englisch) listet alle Felder.
Schritt 3 — Der Python-Client#
Installiere die einzige Abhängigkeit mit pip install websockets (Python 3.9+). Speichere die Datei als pinnfeed_stream.py; die Steam- und die Telegram-Anleitung importieren sie.
# pinnfeed_stream.py: hält eine Live-Kopie des Pinnacle-Quotenbuchs aus dem PinnFeed-WebSocket.
# 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:
"""Events und Märkte nach id, aktualisiert aus Snapshot- und Delta-Frames."""
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):
"""Verbinden, abonnieren, Quotenbuch aktuell halten; mit Backoff endlos neu verbinden."""
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))
Starte ihn mit python pinnfeed_stream.py. Du siehst book ready: N events, M markets und danach eine Zeile pro Preisbewegung.
Schritt 4 — Der Node-Client#
Dieselbe Logik in Node 18+ mit npm install ws. Als stream.mjs speichern und node stream.mjs ausführen.
// stream.mjs: Pinnacle-Quotenbuch live aus dem PinnFeed-WebSocket (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();
Schritt 5 — Das Abo eingrenzen#
Die Filter greifen serverseitig, du empfängst und verarbeitest also nur, was du anforderst:
{ "type": "subscribe", "sports": ["tennis"], "live": true, "markets": ["moneyline", "total"], "main": true }
sports: Slugs oder ids;leaguesundevents: Listen von ids.live:truenur Live,falsenur Pre-Match; weglassen für beides.main: truebehält pro Event nur Hauptsiegwette, Haupthandicap und Haupttotal.- Ein erneutes
subscribeersetzt das Abo und schickt einen neuen Snapshot.
Checkliste für den Produktivbetrieb#
- Mit Backoff und Jitter neu verbinden (beide Clients tun das: 1 s, 2 s, 4 s, bis 30 s) und aus dem neuen Snapshot neu aufbauen.
- Kompression aktivieren: Pythons
websocketshandeltpermessage-deflatestandardmäßig aus; in NodeperMessageDeflate: trueübergeben. Snapshots schrumpfen um den Faktor 5 bis 10. - Schnell lesen. Staut sich für einen langsamen Client mehr als 16 MB an, schließt der Server ihn mit Code
4008. Schwere Arbeit gehört außerhalb der Socket-Schleife. seqfürs Logging nutzen, nicht zur Lückenerkennung: Filter überspringen Batches, die du nicht abonniert hast.
Häufige Fragen#
Welchen Plan brauche ich für den WebSocket?#
Builder (99 €/Monat) oder höher. Free- und Hobby-Keys erhalten auf dem WebSocket 403 plan_lacks_ws und können REST nutzen.
Wie viele Verbindungen kann ich öffnen?#
Builder erlaubt 3 WebSocket-Verbindungen pro Konto, Pro 10.
Warum haben manche Ausgänge Teilnehmer-ids statt home oder away?#
Specials und manche Langzeitwetten listen Teilnehmer statt Heim- und Auswärtsteam. Abonniere mit "specials": false und filtere auf home, away, draw, over und under, wenn du nur Spiellinien willst, so wie die Beispiele.