Files
arbpulse_github/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/design.md
T
Mauricio Barragan 4ffce9a59e fix(exchanges): truncate Kraken book to subscribed depth + crossed-book guard (WAY-77)
Kraken WS v2 book channel does not send deletes for levels evicted from
its top-N window; without client-side truncation those levels lingered
forever as phantom quotes, eventually crossing the local book (bid >= ask)
and feeding the engine a fake permanent arbitrage (~$62.9M bogus P&L).

- BookSide.truncate() removes levels beyond the best depth prices from
  the internal map (not just the emitted array)
- KrakenConnector uses depth 10 consistently (subscription + LocalBook)
  and truncates both sides after every update
- ExchangeConnector.emit() drops internally crossed books, logs and
  forces a resync (book reset + reconnect for a fresh snapshot)
- Unit tests for truncation and the crossed-book guard
- OpenSpec: order-book-integrity spec; change archived (2026-07-19)

Refs: Linear WAY-77
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 11:43:12 -06:00

4.2 KiB

Design — fix-kraken-phantom-book-levels

Context

Los conectores WS mantienen un LocalBook (Map precio→qty por lado) que se actualiza con snapshots + deltas. Kraken v2 (book, depth 10) solo gestiona la ventana top-10: cuando un nivel sale de la ventana porque entran precios mejores, no envía delete — el cliente debe truncar su copia local tras cada update (documentado en Kraken WS v2). Hoy BookSide.apply() solo borra con qty <= 0, y toArray() capea el display a depth pero el Map conserva los niveles huérfanos. Como bids se ordenan desc y asks asc, un bid fantasma alto o un ask fantasma bajo queda siempre en el tope del array emitido. Resultado observado en prod: libro de Kraken cruzado (bid > ask) durante horas y $62.9M de P&L ficticio.

Agravante: ExchangeConnector.depth = 15 mientras Kraken se suscribe con depth: 10 — hasta el cap de display admite 5 niveles que Kraken jamás va a actualizar.

Goals / Non-Goals

Goals:

  • El libro local de Kraken refleja fielmente la ventana top-10 del exchange.
  • Un libro internamente cruzado nunca llega al ArbitrageEngine.
  • Recuperación automática ante corrupción (re-sync), sin intervención manual.
  • Tests unitarios que cubran truncado y guard.

Non-Goals:

  • Validación del checksum CRC32 de Kraken (mejora futura; el truncado + guard cubren el fallo observado con mucho menos código).
  • Cambios en Bybit/OKX/Binance (OKX y Binance reemplazan el libro completo por mensaje; Bybit manda deletes explícitos para su ventana de 50).
  • Cambios de API REST, SSE o frontend.

Decisions

  1. BookSide.truncate() borra del Map, no solo del display. Tras aplicar los updates de un mensaje, se ordena por mejor precio y se eliminan los niveles más allá de depth. Alternativa considerada: pasar de Map a array ordenado permanente — descartada, complica apply() O(1) y el hot path no lo necesita (depth ≤ 50, truncar tras cada mensaje es barato).

  2. El truncado se invoca desde el conector de Kraken con su depth real (10). Es un requisito del protocolo de Kraken, no un comportamiento universal: Bybit mantiene ventana 50 con deletes explícitos, OKX/Binance resetean por mensaje. Alternativa: truncar siempre en emit() de la base — descartada porque mezclaría semánticas distintas por exchange y ocultaría el contrato.

  3. Depth por conector. ExchangeConnector.depth pasa a ser sobreescribible y Kraken lo fija en 10, igual a su suscripción. Se elimina el mismatch 15/10.

  4. Guard de libro cruzado en emit() de la base, con auto-recovery. Si bids[0].price >= asks[0].price: no se emite, se loguea warn y se fuerza re-sync cerrando el socket (ws.close() → el reconnect existente con backoff re-suscribe y Kraken re-manda snapshot). Alternativa: solo descartar la emisión — descartada porque el libro seguiría corrupto y el quote se volvería stale silenciosamente; reconectar restaura el dato. El guard vive en la base porque protege a todos los conectores (defensa en profundidad) y su costo es una comparación por emit.

  5. El estado corrupto acumulado (P&L ficticio) no se migra. El estado es in-memory: el redeploy lo limpia automáticamente.

Risks / Trade-offs

  • [Reconexión en bucle si un exchange emitiera libros cruzados legítimos] → imposible en spot con un libro bien sincronizado; si ocurriera, el backoff exponencial existente (cap 30s) limita el impacto y el log warn lo hace visible.
  • [Truncar en cada mensaje añade un sort O(n log n)] → n ≤ ~20 niveles en Kraken; despreciable frente al parse JSON del propio mensaje.
  • [Sin checksum, otros desyncs sutiles (qty desactualizada dentro de la ventana) no se detectan] → aceptado; el guard de cruce ataja el caso dañino y el checksum queda como mejora futura documentada.

Migration Plan

  1. Merge a dev → PR → main.
  2. Deploy a la VPS (redeploy limpia el estado in-memory, P&L vuelve a 0).
  3. Verificar en prod: /api/state con los 4 exchanges live, libro de Kraken no cruzado, y P&L creciendo de forma realista (mayormente rechazos por fees).

Rollback: revertir el commit; no hay migración de datos.

Open Questions

  • Ninguna bloqueante. Checksum CRC32 de Kraken queda anotado como follow-up.