mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-07-27 15:47:43 +00:00
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:
@@ -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).
|
||||||
+58
@@ -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
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user