Cuotas de Pinnacle en tiempo real por WebSocket con Python y Node
En esta guía construyes un cliente que mantiene en memoria una copia viva de los precios de Pinnacle: se conecta, se suscribe, carga el snapshot, aplica cada delta, responde a los latidos y reconecta solo. Las demás guías (alertas de steam, value betting, el bot de Telegram) se apoyan en él.
Necesitas una clave de PinnFeed en el plan Builder (el plan WebSocket, 99 €/mes, 3 días gratis). Con una clave Free o Hobby puedes seguir los ejemplos REST de la documentación (en inglés).
Paso 1 — Consigue una clave y guárdala en el entorno#
Crea una clave en el panel y expórtala, para que nunca acabe dentro de tu código:
export PINNFEED_KEY="pf_live_..."
Paso 2 — Entiende los frames#
Después de conectarte y enviar subscribe, el servidor manda:
subscribedy luego un snapshot por partes (part,parts,final) con todos los eventos y mercados que cumplen tus filtros;- frames delta, cada uno con una lista de
changes:upserttrae el objeto nuevo completo yremovesolo su id; - un ping cada 20 segundos. Responde con
{"type":"pong"}o el servidor cerrará el socket tras 60 segundos de silencio.
Los mercados se identifican por id (eventId:key), así que un diccionario es todo el estado que necesitas. Cada resultado lleva decimal, american, fair (cuota justa sin margen) y fairProb. La referencia del WebSocket (en inglés) lista todos los campos.
Paso 3 — El cliente en Python#
Instala la única dependencia con pip install websockets (Python 3.9+). Guárdalo como pinnfeed_stream.py; las guías de steam y de Telegram lo importan.
# pinnfeed_stream.py: mantiene una copia en vivo del libro de Pinnacle desde el WebSocket de 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:
"""Eventos y mercados por id, actualizados con los 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):
"""Conecta, se suscribe y mantiene el libro al día; reconecta con backoff indefinidamente."""
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))
Ejecútalo con python pinnfeed_stream.py. Verás book ready: N events, M markets y después una línea por cada movimiento de precio.
Paso 4 — El cliente en Node#
La misma lógica en Node 18+ con npm install ws. Guárdalo como stream.mjs y ejecuta node stream.mjs.
// stream.mjs: libro de Pinnacle en vivo desde el WebSocket de 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();
Paso 5 — Acota la suscripción#
Los filtros se aplican en el servidor, así que solo recibes y procesas lo que pides:
{ "type": "subscribe", "sports": ["tennis"], "live": true, "markets": ["moneyline", "total"], "main": true }
sports: slugs o ids;leaguesyevents: listas de ids.live:truesolo en vivo,falsesolo prepartido; si lo omites, ambos.main: truedeja solo el ganador, el hándicap y el total principales de cada evento.- Volver a enviar
subscribesustituye la suscripción y envía un snapshot nuevo.
Lista de comprobación para producción#
- Reconecta con backoff y jitter (los dos clientes lo hacen: 1 s, 2 s, 4 s, hasta 30 s) y reconstruye desde el nuevo snapshot.
- Activa la compresión:
websocketsde Python negociapermessage-deflatepor defecto; en Node pasaperMessageDeflate: true. Los snapshots se reducen entre 5 y 10 veces. - Lee rápido. Si se acumulan más de 16 MB para un cliente lento, el servidor lo cierra con el código
4008. Haz el trabajo pesado fuera del bucle del socket. - Usa
seqpara logs, no para detectar huecos: los filtros se saltan los lotes a los que no estás suscrito.
Preguntas frecuentes#
¿Qué plan necesito para el WebSocket?#
Builder (99 €/mes) o superior. Las claves Free y Hobby reciben 403 plan_lacks_ws en el WebSocket y pueden usar REST.
¿Cuántas conexiones puedo abrir?#
Builder permite 3 conexiones WebSocket por cuenta y Pro, 10.
¿Por qué algunos resultados tienen ids de participante en vez de home o away?#
Los especiales y algunos futuros listan participantes en lugar de local y visitante. Suscríbete con "specials": false y filtra por home, away, draw, over y under si solo quieres líneas de partido, como hacen los ejemplos.