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>
This commit is contained in:
Mauricio Barragan
2026-07-19 11:43:12 -06:00
parent 5d84df3e25
commit 4ffce9a59e
12 changed files with 496 additions and 6 deletions
+1
View File
@@ -164,6 +164,7 @@ Consecuencia esperada en feed **real**: la mayoría de divergencias brutas salen
### Robustez en el hot path ### 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). - **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. - **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). - **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. - **Partial fills** — volumen limitado por profundidad del libro e inventario de wallet.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-19
@@ -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.
@@ -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
<!-- ninguna: `observability` no cambia a nivel de requisitos -->
## 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).
@@ -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
@@ -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)
@@ -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
+72
View File
@@ -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");
});
+27 -3
View File
@@ -16,7 +16,7 @@ export type BookListener = (book: OrderBook) => void;
export abstract class ExchangeConnector implements MarketDataFeed { export abstract class ExchangeConnector implements MarketDataFeed {
abstract readonly id: ExchangeId; abstract readonly id: ExchangeId;
protected abstract readonly url: string; protected abstract readonly url: string;
protected readonly depth = 15; protected readonly depth: number;
protected ws: WebSocket | null = null; protected ws: WebSocket | null = null;
protected book: LocalBook; protected book: LocalBook;
@@ -29,8 +29,10 @@ export abstract class ExchangeConnector implements MarketDataFeed {
protected closed = false; protected closed = false;
private lastEmitTs = 0; private lastEmitTs = 0;
constructor() { /** `depth` must match the depth the connector subscribes with. */
this.book = new LocalBook(this.depth); constructor(depth = 15) {
this.depth = depth;
this.book = new LocalBook(depth);
} }
onBook(listener: BookListener): void { onBook(listener: BookListener): void {
@@ -165,9 +167,31 @@ export abstract class ExchangeConnector implements MarketDataFeed {
exchangeTs, exchangeTs,
}; };
if (book.bids.length === 0 || book.asks.length === 0) return; 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); 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. */ /** Combined-stream URLs (e.g. Binance) set this to skip the subscribe send. */
protected skipSubscribe(): boolean { protected skipSubscribe(): boolean {
return false; return false;
+11 -1
View File
@@ -19,20 +19,29 @@ interface KrakenMessage {
data?: KrakenBookData[]; data?: KrakenBookData[];
} }
const KRAKEN_BOOK_DEPTH = 10;
/** /**
* Kraken WebSocket v2 — `book` channel. * Kraken WebSocket v2 — `book` channel.
* Docs: https://docs.kraken.com/websockets-v2/ * Docs: https://docs.kraken.com/websockets-v2/
* Snapshot replaces the book; updates patch individual price levels (qty 0 = remove). * 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 { export class KrakenConnector extends ExchangeConnector {
readonly id: ExchangeId = "kraken"; readonly id: ExchangeId = "kraken";
protected readonly url = "wss://ws.kraken.com/v2"; protected readonly url = "wss://ws.kraken.com/v2";
private readonly symbol = "BTC/USDT"; private readonly symbol = "BTC/USDT";
constructor() {
super(KRAKEN_BOOK_DEPTH);
}
protected subscribeMessage(): unknown { protected subscribeMessage(): unknown {
return { return {
method: "subscribe", 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.bids ?? []) this.book.bids.apply(lvl.price, lvl.qty);
for (const lvl of data.asks ?? []) this.book.asks.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; const exchangeTs = data.timestamp ? Date.parse(data.timestamp) : null;
this.emit(Number.isFinite(exchangeTs) ? exchangeTs : null); this.emit(Number.isFinite(exchangeTs) ? exchangeTs : null);
@@ -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",
);
});
+27 -2
View File
@@ -8,7 +8,10 @@ import type { Level } from "../../domain/entities/index.js";
export class BookSide { export class BookSide {
private levels = new Map<number, number>(); private levels = new Map<number, number>();
constructor(private readonly side: "bid" | "ask", private readonly depth: number) {} constructor(
private readonly side: "bid" | "ask",
private readonly depth: number,
) {}
clear(): void { clear(): void {
this.levels.clear(); 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. */ /** Sorted (bids desc, asks asc) and capped to `depth` levels. */
toArray(): Level[] { toArray(): Level[] {
const arr: Level[] = []; const arr: Level[] = [];
for (const [price, qty] of this.levels) arr.push({ price, qty }); 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; return arr.length > this.depth ? arr.slice(0, this.depth) : arr;
} }
@@ -48,4 +67,10 @@ export class LocalBook {
this.bids.clear(); this.bids.clear();
this.asks.clear(); this.asks.clear();
} }
/** Truncate both sides to their depth (see BookSide.truncate). */
truncate(): void {
this.bids.truncate();
this.asks.truncate();
}
} }