Files
Mauricio BarraganandCursor 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

90 lines
4.2 KiB
Markdown

# 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.