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>
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
-
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á dedepth. Alternativa considerada: pasar de Map a array ordenado permanente — descartada, complicaapply()O(1) y el hot path no lo necesita (depth ≤ 50, truncar tras cada mensaje es barato). -
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. -
Depth por conector.
ExchangeConnector.depthpasa a ser sobreescribible y Kraken lo fija en 10, igual a su suscripción. Se elimina el mismatch 15/10. -
Guard de libro cruzado en
emit()de la base, con auto-recovery. Sibids[0].price >= asks[0].price: no se emite, se logueawarny 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. -
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
warnlo 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
- Merge a
dev→ PR →main. - Deploy a la VPS (redeploy limpia el estado in-memory, P&L vuelve a 0).
- Verificar en prod:
/api/statecon los 4 exchangeslive, 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.