mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-07-28 08:07:43 +00:00
4ffce9a59e
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>
77 lines
2.0 KiB
TypeScript
77 lines
2.0 KiB
TypeScript
import type { Level } from "../../domain/entities/index.js";
|
|
|
|
/**
|
|
* Maintains one side of an order book from snapshot + incremental deltas.
|
|
* Internally a price->qty map; emits a sorted, depth-capped array on demand.
|
|
* A qty of 0 removes the price level (standard exchange convention).
|
|
*/
|
|
export class BookSide {
|
|
private levels = new Map<number, number>();
|
|
|
|
constructor(
|
|
private readonly side: "bid" | "ask",
|
|
private readonly depth: number,
|
|
) {}
|
|
|
|
clear(): void {
|
|
this.levels.clear();
|
|
}
|
|
|
|
apply(price: number, qty: number): void {
|
|
if (qty <= 0) {
|
|
this.levels.delete(price);
|
|
} else {
|
|
this.levels.set(price, qty);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
);
|
|
return arr.length > this.depth ? arr.slice(0, this.depth) : arr;
|
|
}
|
|
|
|
get size(): number {
|
|
return this.levels.size;
|
|
}
|
|
}
|
|
|
|
export class LocalBook {
|
|
readonly bids: BookSide;
|
|
readonly asks: BookSide;
|
|
|
|
constructor(depth: number) {
|
|
this.bids = new BookSide("bid", depth);
|
|
this.asks = new BookSide("ask", depth);
|
|
}
|
|
|
|
reset(): void {
|
|
this.bids.clear();
|
|
this.asks.clear();
|
|
}
|
|
|
|
/** Truncate both sides to their depth (see BookSide.truncate). */
|
|
truncate(): void {
|
|
this.bids.truncate();
|
|
this.asks.truncate();
|
|
}
|
|
}
|