From 4ffce9a59ef0d6b68ee33facc4b55c0c9a4cbfe2 Mon Sep 17 00:00:00 2001 From: Mauricio Barragan Date: Sun, 19 Jul 2026 11:43:12 -0600 Subject: [PATCH] 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 --- README.md | 1 + .../.openspec.yaml | 2 + .../design.md | 89 +++++++++++++++++++ .../proposal.md | 32 +++++++ .../specs/order-book-integrity/spec.md | 58 ++++++++++++ .../tasks.md | 26 ++++++ openspec/specs/order-book-integrity/spec.md | 69 ++++++++++++++ src/infrastructure/exchanges/base.test.ts | 72 +++++++++++++++ src/infrastructure/exchanges/base.ts | 30 ++++++- src/infrastructure/exchanges/kraken.ts | 12 ++- .../exchanges/local-book.test.ts | 82 +++++++++++++++++ src/infrastructure/exchanges/local-book.ts | 29 +++++- 12 files changed, 496 insertions(+), 6 deletions(-) create mode 100644 openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/design.md create mode 100644 openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/proposal.md create mode 100644 openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/specs/order-book-integrity/spec.md create mode 100644 openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/tasks.md create mode 100644 openspec/specs/order-book-integrity/spec.md create mode 100644 src/infrastructure/exchanges/base.test.ts create mode 100644 src/infrastructure/exchanges/local-book.test.ts diff --git a/README.md b/README.md index 3bc5a3d..3621dd2 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ Consecuencia esperada en feed **real**: la mayoría de divergencias brutas salen ### Robustez en el hot path - **One trade per tick** — si varios pares confirman en el mismo tick, solo se ejecuta el de mayor `netProfit` (desempate por `netProfitPct` y par lexicográfico). +- **Integridad del libro local** — los feeds delta con ventana top-N (Kraken v2) se truncan al depth suscrito tras cada update (Kraken no manda deletes para niveles expulsados de la ventana); un libro internamente cruzado (bid ≥ ask) nunca se emite al engine: se descarta, se loguea y se fuerza re-sync vía reconexión. - **Staleness** — quotes más viejos que `STALE_MS` no disparan ejecución. - **Anti-flicker** — la divergencia debe persistir `FLICKER_CONFIRM_MS` antes de actuar (filtra artefactos de latencia). - **Partial fills** — volumen limitado por profundidad del libro e inventario de wallet. diff --git a/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/.openspec.yaml b/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/.openspec.yaml new file mode 100644 index 0000000..eb139cc --- /dev/null +++ b/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-19 diff --git a/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/design.md b/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/design.md new file mode 100644 index 0000000..fa02620 --- /dev/null +++ b/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/design.md @@ -0,0 +1,89 @@ +# 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. diff --git a/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/proposal.md b/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/proposal.md new file mode 100644 index 0000000..6799465 --- /dev/null +++ b/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/proposal.md @@ -0,0 +1,32 @@ +# Fix: niveles fantasma en el order book de Kraken + +> Issue: [WAY-77](https://linear.app/wayool/issue/WAY-77/arb-niveles-fantasma-en-el-order-book-de-kraken-inflan-el-pandl) — `[ARB] Niveles fantasma en el order book de Kraken inflan el P&L ($62.9M ficticios)` + +## Why + +En producción el P&L realizado llegó a $62.9M ficticios: el libro local de Kraken quedó **cruzado** (bid 64,925.90 > ask 64,316.20, spread -609) con niveles viejos de hace horas (coinciden con el high/low de 24h de Kraken). El motor vio un arbitraje permanente de ~0.5% vendiendo en Kraken y ejecutó ~674k trades falsos. La causa: el conector de Kraken v2 nunca trunca el libro local al depth suscrito, y el protocolo de Kraken **no envía deletes** para niveles que salen de la ventana top-N — exige que el cliente trunque tras cada update. + +## What Changes + +- `BookSide` (`src/infrastructure/exchanges/local-book.ts`) gana un método `truncate()` que elimina del Map los niveles fuera de los mejores `depth` precios (no solo en el display). +- El conector de Kraken (`src/infrastructure/exchanges/kraken.ts`) trunca ambos lados tras aplicar cada update, usando el depth suscrito (10). +- Se corrige el mismatch de depth: el conector de Kraken suscribe y mantiene el mismo depth (hoy: base mantiene 15, suscripción pide 10). +- Guard de libro cruzado en `ExchangeConnector.emit()`: si `bids[0].price >= asks[0].price`, no se emite el libro, se loguea y se resetea el libro local para forzar re-sincronización (Kraken re-manda snapshot al reconectar/resuscribir). +- Documentación actualizada: skill `exchange-ws` (nota de truncado obligatorio en Kraken v2) y README si aplica. + +## Capabilities + +### New Capabilities + +- `order-book-integrity`: mantenimiento correcto del libro local por exchange — truncado al depth suscrito en feeds delta (Kraken), detección de libro cruzado como señal de corrupción, y re-sincronización en lugar de emitir datos corruptos al motor. + +### Modified Capabilities + + + +## Impact + +- **Código:** `src/infrastructure/exchanges/local-book.ts`, `src/infrastructure/exchanges/kraken.ts`, `src/infrastructure/exchanges/base.ts`. +- **Tests:** nuevos unit tests de `BookSide.truncate` y del guard de libro cruzado. +- **Comportamiento:** el motor deja de recibir libros corruptos; el P&L vuelve a ser realista (mayormente `rejected · fees`, que es lo correcto en mercados eficientes). +- **Sin cambios de API/contrato REST ni de frontend.** Tras el deploy se requiere un Reset manual del estado para limpiar el P&L ficticio acumulado (estado in-memory: el redeploy ya lo limpia solo). diff --git a/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/specs/order-book-integrity/spec.md b/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/specs/order-book-integrity/spec.md new file mode 100644 index 0000000..6a1b585 --- /dev/null +++ b/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/specs/order-book-integrity/spec.md @@ -0,0 +1,58 @@ +# order-book-integrity + +## ADDED Requirements + +### Requirement: Truncado del libro local al depth suscrito en feeds delta + +`BookSide` SHALL exponer una operación `truncate()` que elimine del estado +interno (no solo de la salida) todos los niveles de precio más allá de los +mejores `depth` niveles del lado (bids: precios más altos; asks: precios más +bajos). El conector de Kraken SHALL invocar el truncado en ambos lados tras +aplicar cada mensaje `update`, usando el mismo depth con el que se suscribió +al canal `book`. + +#### Scenario: Nivel que sale de la ventana top-N se elimina + +- **WHEN** el libro local de bids contiene `depth` niveles y un update añade un + bid con precio mejor que todos los existentes +- **THEN** tras el truncado el nivel con peor precio ya no existe en el estado + interno del `BookSide` y el tamaño del lado es exactamente `depth` + +#### Scenario: Bid fantasma no sobrevive al movimiento del mercado + +- **WHEN** el precio de mercado baja y sucesivos updates llenan la ventana + top-N con precios inferiores a un bid antiguo que Kraken ya no reporta +- **THEN** el bid antiguo es eliminado por truncado y el mejor bid emitido + refleja la ventana real del exchange + +### Requirement: Depth del conector consistente con la suscripción + +Cada conector SHALL mantener su libro local con el mismo depth que solicita en +su suscripción. El conector de Kraken SHALL usar depth 10 tanto en el mensaje +de suscripción como en su `LocalBook`. + +#### Scenario: Sin niveles residuales por mismatch de depth + +- **WHEN** el conector de Kraken arranca y se suscribe al canal `book` +- **THEN** el depth del `LocalBook` es igual al depth de la suscripción (10) + +### Requirement: Guard de libro cruzado con re-sincronización + +`ExchangeConnector` SHALL detectar antes de emitir cuando el libro normalizado +está internamente cruzado (`bids[0].price >= asks[0].price`). En ese caso el +conector MUST NOT emitir el libro a los listeners, SHALL registrar el evento en +el log, y SHALL forzar una re-sincronización (reset del libro local y +reconexión del WebSocket para recibir un snapshot fresco). + +#### Scenario: Libro cruzado no llega al motor + +- **WHEN** el libro local de un exchange queda con mejor bid ≥ mejor ask +- **THEN** no se emite ningún `OrderBook` a los listeners y el + `ArbitrageEngine` no evalúa ese libro + +#### Scenario: Recuperación automática tras corrupción + +- **WHEN** se detecta un libro cruzado +- **THEN** el conector resetea su libro local y fuerza reconexión, y tras el + snapshot de re-suscripción vuelve a emitir libros consistentes sin + intervención manual diff --git a/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/tasks.md b/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/tasks.md new file mode 100644 index 0000000..21e07ab --- /dev/null +++ b/openspec/changes/archive/2026-07-19-fix-kraken-phantom-book-levels/tasks.md @@ -0,0 +1,26 @@ +# Tasks — fix-kraken-phantom-book-levels + +## 1. LocalBook: truncado real + +- [x] Añadir `BookSide.truncate()` que elimine del Map los niveles fuera de los mejores `depth` precios del lado +- [x] Unit tests de `truncate()`: elimina el peor nivel al exceder depth, no toca nada si size <= depth, y el bid fantasma desaparece tras updates sucesivos + +## 2. Conector Kraken + +- [x] Hacer `ExchangeConnector.depth` sobreescribible por subclase e inicializar `LocalBook` con el depth del conector +- [x] Fijar depth 10 en `KrakenConnector` (igual a la suscripción) y truncar ambos lados tras aplicar cada update + +## 3. Guard de libro cruzado + +- [x] En `ExchangeConnector.emit()`: si `bids[0].price >= asks[0].price`, no emitir, log warn, reset del libro y reconexión para re-sync +- [x] Unit test del guard: libro cruzado no se emite a listeners y dispara re-sync + +## 4. Documentación + +- [x] Actualizar skill `exchange-ws` (truncado obligatorio en Kraken v2, guard de cruce en la base, depth por conector) +- [x] Revisar README/AGENTS por menciones al manejo del libro que queden desactualizadas + +## 5. Verificación + +- [x] `npm run typecheck` + `npm test` en verde +- [x] Arrancar en local con feeds reales y verificar via `/api/state` que Kraken emite libro no cruzado y quotes coherentes con el mercado (matar el proceso al terminar) diff --git a/openspec/specs/order-book-integrity/spec.md b/openspec/specs/order-book-integrity/spec.md new file mode 100644 index 0000000..5688698 --- /dev/null +++ b/openspec/specs/order-book-integrity/spec.md @@ -0,0 +1,69 @@ +# order-book-integrity + +## Purpose + +Garantizar que el libro local de cada exchange refleje fielmente el estado real +del venue: truncado al depth suscrito en feeds delta con ventana top-N, +detección de libros internamente cruzados como señal de corrupción, y +re-sincronización automática en lugar de emitir datos corruptos al motor de +arbitraje. + +Origen: incidente de niveles fantasma en Kraken (Linear WAY-77, change +`fix-kraken-phantom-book-levels`). + +## Requirements + +### Requirement: Truncado del libro local al depth suscrito en feeds delta + +`BookSide` SHALL exponer una operación `truncate()` que elimine del estado +interno (no solo de la salida) todos los niveles de precio más allá de los +mejores `depth` niveles del lado (bids: precios más altos; asks: precios más +bajos). El conector de Kraken SHALL invocar el truncado en ambos lados tras +aplicar cada mensaje `update`, usando el mismo depth con el que se suscribió +al canal `book`. + +#### Scenario: Nivel que sale de la ventana top-N se elimina + +- **WHEN** el libro local de bids contiene `depth` niveles y un update añade un + bid con precio mejor que todos los existentes +- **THEN** tras el truncado el nivel con peor precio ya no existe en el estado + interno del `BookSide` y el tamaño del lado es exactamente `depth` + +#### Scenario: Bid fantasma no sobrevive al movimiento del mercado + +- **WHEN** el precio de mercado baja y sucesivos updates llenan la ventana + top-N con precios inferiores a un bid antiguo que Kraken ya no reporta +- **THEN** el bid antiguo es eliminado por truncado y el mejor bid emitido + refleja la ventana real del exchange + +### Requirement: Depth del conector consistente con la suscripción + +Cada conector SHALL mantener su libro local con el mismo depth que solicita en +su suscripción. El conector de Kraken SHALL usar depth 10 tanto en el mensaje +de suscripción como en su `LocalBook`. + +#### Scenario: Sin niveles residuales por mismatch de depth + +- **WHEN** el conector de Kraken arranca y se suscribe al canal `book` +- **THEN** el depth del `LocalBook` es igual al depth de la suscripción (10) + +### Requirement: Guard de libro cruzado con re-sincronización + +`ExchangeConnector` SHALL detectar antes de emitir cuando el libro normalizado +está internamente cruzado (`bids[0].price >= asks[0].price`). En ese caso el +conector MUST NOT emitir el libro a los listeners, SHALL registrar el evento en +el log, y SHALL forzar una re-sincronización (reset del libro local y +reconexión del WebSocket para recibir un snapshot fresco). + +#### Scenario: Libro cruzado no llega al motor + +- **WHEN** el libro local de un exchange queda con mejor bid ≥ mejor ask +- **THEN** no se emite ningún `OrderBook` a los listeners y el + `ArbitrageEngine` no evalúa ese libro + +#### Scenario: Recuperación automática tras corrupción + +- **WHEN** se detecta un libro cruzado +- **THEN** el conector resetea su libro local y fuerza reconexión, y tras el + snapshot de re-suscripción vuelve a emitir libros consistentes sin + intervención manual diff --git a/src/infrastructure/exchanges/base.test.ts b/src/infrastructure/exchanges/base.test.ts new file mode 100644 index 0000000..33aaf69 --- /dev/null +++ b/src/infrastructure/exchanges/base.test.ts @@ -0,0 +1,72 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { ExchangeId, OrderBook } from "../../domain/entities/index.js"; +import { ExchangeConnector } from "./base.js"; + +/** Minimal connector for exercising emit() without a real WebSocket. */ +class TestConnector extends ExchangeConnector { + readonly id: ExchangeId = "kraken"; + protected readonly url = "ws://unused"; + resyncCount = 0; + + constructor() { + super(5); + } + + protected subscribeMessage(): unknown { + return {}; + } + + protected handleMessage(): void {} + + protected override resync(): void { + this.resyncCount += 1; + super.resync(); + } + + applyLevels(bids: [number, number][], asks: [number, number][]): void { + for (const [price, qty] of bids) this.book.bids.apply(price, qty); + for (const [price, qty] of asks) this.book.asks.apply(price, qty); + } + + emitNow(): void { + this.emit(null); + } + + get bookSize(): number { + return this.book.bids.size + this.book.asks.size; + } +} + +test("normal book is emitted to listeners", () => { + const c = new TestConnector(); + const received: OrderBook[] = []; + c.onBook((b) => received.push(b)); + + c.applyLevels([[64560, 1]], [[64561, 1]]); + c.emitNow(); + + assert.equal(received.length, 1); + assert.equal(received[0]?.bids[0]?.price, 64560); + assert.equal(c.resyncCount, 0); +}); + +test("crossed book is not emitted and triggers resync", () => { + const c = new TestConnector(); + const received: OrderBook[] = []; + c.onBook((b) => received.push(b)); + + // Phantom bid above the real ask: corrupted local book. + c.applyLevels( + [ + [64925.9, 0.15], + [64560, 1], + ], + [[64561, 1]], + ); + c.emitNow(); + + assert.equal(received.length, 0, "corrupted book must not reach listeners"); + assert.equal(c.resyncCount, 1); + assert.equal(c.bookSize, 0, "local book is reset for a fresh snapshot"); +}); diff --git a/src/infrastructure/exchanges/base.ts b/src/infrastructure/exchanges/base.ts index 5cc9398..b889bca 100644 --- a/src/infrastructure/exchanges/base.ts +++ b/src/infrastructure/exchanges/base.ts @@ -16,7 +16,7 @@ export type BookListener = (book: OrderBook) => void; export abstract class ExchangeConnector implements MarketDataFeed { abstract readonly id: ExchangeId; protected abstract readonly url: string; - protected readonly depth = 15; + protected readonly depth: number; protected ws: WebSocket | null = null; protected book: LocalBook; @@ -29,8 +29,10 @@ export abstract class ExchangeConnector implements MarketDataFeed { protected closed = false; private lastEmitTs = 0; - constructor() { - this.book = new LocalBook(this.depth); + /** `depth` must match the depth the connector subscribes with. */ + constructor(depth = 15) { + this.depth = depth; + this.book = new LocalBook(depth); } onBook(listener: BookListener): void { @@ -165,9 +167,31 @@ export abstract class ExchangeConnector implements MarketDataFeed { exchangeTs, }; if (book.bids.length === 0 || book.asks.length === 0) return; + + // A crossed book (best bid >= best ask) is impossible on a spot venue: + // it means our local copy is corrupted (e.g. phantom levels). Never feed + // it to the engine — drop it and force a fresh snapshot via reconnect. + const bestBid = book.bids[0]!; + const bestAsk = book.asks[0]!; + if (bestBid.price >= bestAsk.price) { + this.log.warn("crossed local book detected, forcing resync", { + bid: bestBid.price, + ask: bestAsk.price, + }); + this.resync(); + return; + } + for (const listener of this.listeners) listener(book); } + /** Drop the corrupted local book and reconnect to receive a fresh snapshot. */ + protected resync(): void { + this.book.reset(); + // close() triggers the existing reconnect-with-backoff path (unless stopped). + this.ws?.close(); + } + /** Combined-stream URLs (e.g. Binance) set this to skip the subscribe send. */ protected skipSubscribe(): boolean { return false; diff --git a/src/infrastructure/exchanges/kraken.ts b/src/infrastructure/exchanges/kraken.ts index e686104..f6b48a4 100644 --- a/src/infrastructure/exchanges/kraken.ts +++ b/src/infrastructure/exchanges/kraken.ts @@ -19,20 +19,29 @@ interface KrakenMessage { data?: KrakenBookData[]; } +const KRAKEN_BOOK_DEPTH = 10; + /** * Kraken WebSocket v2 — `book` channel. * Docs: https://docs.kraken.com/websockets-v2/ * Snapshot replaces the book; updates patch individual price levels (qty 0 = remove). + * Kraken does NOT send deletes for levels evicted from the top-N window: the + * client must truncate its local book to the subscribed depth after every + * update, or evicted levels linger forever as phantom quotes. */ export class KrakenConnector extends ExchangeConnector { readonly id: ExchangeId = "kraken"; protected readonly url = "wss://ws.kraken.com/v2"; private readonly symbol = "BTC/USDT"; + constructor() { + super(KRAKEN_BOOK_DEPTH); + } + protected subscribeMessage(): unknown { return { method: "subscribe", - params: { channel: "book", symbol: [this.symbol], depth: 10 }, + params: { channel: "book", symbol: [this.symbol], depth: this.depth }, }; } @@ -49,6 +58,7 @@ export class KrakenConnector extends ExchangeConnector { for (const lvl of data.bids ?? []) this.book.bids.apply(lvl.price, lvl.qty); for (const lvl of data.asks ?? []) this.book.asks.apply(lvl.price, lvl.qty); + this.book.truncate(); const exchangeTs = data.timestamp ? Date.parse(data.timestamp) : null; this.emit(Number.isFinite(exchangeTs) ? exchangeTs : null); diff --git a/src/infrastructure/exchanges/local-book.test.ts b/src/infrastructure/exchanges/local-book.test.ts new file mode 100644 index 0000000..3e9ee8d --- /dev/null +++ b/src/infrastructure/exchanges/local-book.test.ts @@ -0,0 +1,82 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { BookSide, LocalBook } from "./local-book.js"; + +test("truncate removes worst bid levels beyond depth", () => { + const bids = new BookSide("bid", 3); + for (const price of [100, 101, 102]) bids.apply(price, 1); + // A better bid arrives; Kraken sends no delete for the evicted 100. + bids.apply(103, 1); + bids.truncate(); + + assert.equal(bids.size, 3); + assert.deepEqual( + bids.toArray().map((l) => l.price), + [103, 102, 101], + ); +}); + +test("truncate removes worst ask levels beyond depth", () => { + const asks = new BookSide("ask", 3); + for (const price of [100, 101, 102]) asks.apply(price, 1); + asks.apply(99, 1); + asks.truncate(); + + assert.equal(asks.size, 3); + assert.deepEqual( + asks.toArray().map((l) => l.price), + [99, 100, 101], + ); +}); + +test("truncate is a no-op when size <= depth", () => { + const bids = new BookSide("bid", 5); + bids.apply(100, 1); + bids.apply(101, 2); + bids.truncate(); + + assert.equal(bids.size, 2); + assert.deepEqual( + bids.toArray().map((l) => l.price), + [101, 100], + ); +}); + +test("phantom high bid disappears across a rally-then-drop (Kraken window)", () => { + const depth = 3; + const book = new LocalBook(depth); + + // Snapshot during a rally. + book.bids.apply(64920, 1); + book.bids.apply(64921, 1); + book.bids.apply(64922, 1); + book.truncate(); + + // Higher bids push the low ones out of Kraken's top-3 window. Kraken sends + // NO delete for the evicted 64920/64921 — only the client-side truncate + // removes them. Without it they linger as phantoms. + book.bids.apply(64924, 1); + book.bids.apply(64925.9, 0.15); + book.truncate(); + + // Market drops: in-window levels get explicit qty-0 deletes and lower bids + // enter the window. + book.bids.apply(64925.9, 0); + book.bids.apply(64924, 0); + book.bids.apply(64922, 0); + book.bids.apply(64564, 1); + book.bids.apply(64563, 1); + book.bids.apply(64562, 1); + book.truncate(); + + const top = book.bids.toArray(); + assert.equal(top.length, depth); + assert.deepEqual( + top.map((l) => l.price), + [64564, 64563, 64562], + ); + assert.ok( + top.every((l) => l.price < 64900), + "no phantom bid survives", + ); +}); diff --git a/src/infrastructure/exchanges/local-book.ts b/src/infrastructure/exchanges/local-book.ts index 000048b..47aa62b 100644 --- a/src/infrastructure/exchanges/local-book.ts +++ b/src/infrastructure/exchanges/local-book.ts @@ -8,7 +8,10 @@ import type { Level } from "../../domain/entities/index.js"; export class BookSide { private levels = new Map(); - constructor(private readonly side: "bid" | "ask", private readonly depth: number) {} + constructor( + private readonly side: "bid" | "ask", + private readonly depth: number, + ) {} clear(): void { this.levels.clear(); @@ -22,11 +25,27 @@ export class BookSide { } } + /** + * Remove levels beyond the best `depth` prices from the internal map. + * Required by delta feeds that do NOT send deletes for levels evicted from + * their top-N window (e.g. Kraken v2 `book`): without this, evicted levels + * linger forever as phantom quotes. + */ + truncate(): void { + if (this.levels.size <= this.depth) return; + const prices = [...this.levels.keys()].sort((a, b) => + this.side === "bid" ? b - a : a - b, + ); + for (const price of prices.slice(this.depth)) this.levels.delete(price); + } + /** Sorted (bids desc, asks asc) and capped to `depth` levels. */ toArray(): Level[] { const arr: Level[] = []; for (const [price, qty] of this.levels) arr.push({ price, qty }); - arr.sort((a, b) => (this.side === "bid" ? b.price - a.price : a.price - b.price)); + arr.sort((a, b) => + this.side === "bid" ? b.price - a.price : a.price - b.price, + ); return arr.length > this.depth ? arr.slice(0, this.depth) : arr; } @@ -48,4 +67,10 @@ export class LocalBook { this.bids.clear(); this.asks.clear(); } + + /** Truncate both sides to their depth (see BookSide.truncate). */ + truncate(): void { + this.bids.truncate(); + this.asks.truncate(); + } }