On the wire · 14:55:35 UTC Live 79 · Prematch 8,495 · Markets 34,202 Vol. I · No. 1 · Monday 9 November 2026 wss://pinnfeed.io/v1/stream
PinnFeed
Guía · Python · Node

Cuotas de Pinnacle en tiempo real por WebSocket con Python y Node

Actualizado el · PinnFeed · 5 min de lectura

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:

  1. subscribed y luego un snapshot por partes (part, parts, final) con todos los eventos y mercados que cumplen tus filtros;
  2. frames delta, cada uno con una lista de changes: upsert trae el objeto nuevo completo y remove solo su id;
  3. 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; leagues y events: listas de ids.
  • live: true solo en vivo, false solo prepartido; si lo omites, ambos.
  • main: true deja solo el ganador, el hándicap y el total principales de cada evento.
  • Volver a enviar subscribe sustituye 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: websockets de Python negocia permessage-deflate por defecto; en Node pasa perMessageDeflate: 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 seq para 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.

El plan WebSocket: Builder, 99 €/mes.

Cuotas de Pinnacle en tiempo real por un único WebSocket: los 13 deportes, todos los mercados, cuotas justas sin margen y cuotas de cierre. 3 días gratis y cancelas con 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.