Repository Public
1/**2 * Pure reducers of the terminal's market state: order book (accept / clean / group), trades (merge), candles (aggregate / tick).3 * Book and trade values stay decimal strings (sums through `D`); candle maths for the chart uses numbers.4 */5import { D, dec, type Dec } from './dec';6import type { Book, Candle, PublicTrade } from './types';78type Level = [string, string];910/** keeps positive, well-formed levels, sorted best first, duplicates of a price summed */11function cleanSide(levels: readonly Level[] | undefined, desc: boolean): Level[] {12 const byPrice = new Map<string, { p: Dec; s: Dec }>();13 for (const l of levels ?? []) {14 const p = dec(l?.[0]), s = dec(l?.[1]);15 if (!p || !s || !D.isPos(p) || !D.isPos(s)) continue;16 const k = D.str(p), cur = byPrice.get(k);17 byPrice.set(k, { p, s: cur ? D.add(cur.s, s) : s });18 }19 return [...byPrice.values()].sort((a, b) => (desc ? D.cmp(b.p, a.p) : D.cmp(a.p, b.p))).map((x) => [D.str(x.p), D.str(x.s)]);20}2122/**23 * Next book state from a stream / poll snapshot. The stream sends whole books: an update for another instrument or older than24 * the one on screen is ignored (out-of-order delivery after a reconnection); a crossed book loses the levels that cross.25 */26export function mergeBook(prev: Book | null, next: Book | null | undefined, instrument: string): Book | null {27 if (!next || next.instrument !== instrument || !Number.isFinite(next.ts)) return prev;28 if (prev && prev.instrument === instrument && next.ts < prev.ts) return prev;29 let bids = cleanSide(next.bids, true), asks = cleanSide(next.asks, false);30 while (bids.length && asks.length && D.cmp(D.from(bids[0]![0]), D.from(asks[0]![0])) >= 0) {31 // crossed: drop the smaller of the two touching levels (the stale one in practice)32 if (D.cmp(D.from(bids[0]![1]), D.from(asks[0]![1])) <= 0) bids = bids.slice(1); else asks = asks.slice(1);33 }34 return { instrument, bids, asks, ts: next.ts };35}3637export interface BookRow { price: string; size: string; total: string; /** cumulative size / largest cumulative size on screen, 0–1, for the depth bar */ depth: number }38export interface BookView { bids: BookRow[]; asks: BookRow[]; spread: string | null; spreadBps: string | null; mid: string | null }3940/**41 * Levels grouped by `step` (a multiple of the tick: bids round down, asks round up, so a group never looks better than its42 * levels), `rows` per side, with cumulative totals. The spread is always the raw best ask − best bid.43 */44export function groupBook(book: Book | null, step: string, rows: number): BookView {45 const empty: BookView = { bids: [], asks: [], spread: null, spreadBps: null, mid: null };46 const st = dec(step);47 if (!book || !st || !D.isPos(st)) return empty;48 const side = (levels: Level[], mode: 'down' | 'up'): { price: string; size: Dec; total: Dec }[] => {49 const out: { price: string; size: Dec; total: Dec }[] = [];50 let total = D.zero;51 for (const [p, s] of levels) {52 const price = D.str(D.quantize(D.from(p), st, mode)), size = D.from(s);53 total = D.add(total, size);54 const last = out[out.length - 1];55 if (last && last.price === price) { last.size = D.add(last.size, size); last.total = total; }56 else if (out.length === rows) break;57 else out.push({ price, size, total });58 }59 return out;60 };61 const b = side(book.bids, 'down'), a = side(book.asks, 'up');62 const max = Math.max(b.length ? D.toNumber(b[b.length - 1]!.total) : 0, a.length ? D.toNumber(a[a.length - 1]!.total) : 0) || 1;63 const row = (x: { price: string; size: Dec; total: Dec }): BookRow => ({ price: x.price, size: D.str(x.size), total: D.str(x.total), depth: Math.min(1, D.toNumber(x.total) / max) });64 const bb = book.bids[0] ? D.from(book.bids[0][0]) : null, ba = book.asks[0] ? D.from(book.asks[0][0]) : null;65 let spread: string | null = null, spreadBps: string | null = null, mid: string | null = null;66 if (bb && ba) {67 const sp = D.sub(ba, bb), m = D.div(D.add(ba, bb), D.from(2n), 12, 'half_even');68 spread = D.str(sp); mid = D.str(m);69 if (D.isPos(m)) spreadBps = D.str(D.div(D.mul(sp, D.from(10000n)), m, 2, 'half_even'));70 }71 return { bids: b.map(row), asks: a.map(row), spread, spreadBps, mid };72}7374/** newest first, one row per id, at most `cap` */75export function mergeTrades(prev: readonly PublicTrade[], incoming: readonly PublicTrade[], cap = 60): PublicTrade[] {76 if (!incoming.length) return prev as PublicTrade[];77 const seen = new Set<string>(), out: PublicTrade[] = [];78 for (const t of [...incoming, ...prev]) {79 if (!t || typeof t.id !== 'string' || seen.has(t.id) || !Number.isFinite(t.ts)) continue;80 seen.add(t.id); out.push(t);81 }82 out.sort((a, b) => b.ts - a.ts);83 return out.length > cap ? out.slice(0, cap) : out;84}8586/** chart candle: numbers, display only */87export interface Bar { ts: number; o: number; h: number; l: number; c: number; v: number }88export const toBars = (cs: readonly Candle[]): Bar[] => cs.map((c) => ({ ts: c.ts, o: Number(c.open), h: Number(c.high), l: Number(c.low), c: Number(c.close), v: Number(c.volume) }))89 .filter((b) => [b.ts, b.o, b.h, b.l, b.c].every(Number.isFinite)).sort((a, b) => a.ts - b.ts);9091/** candles of a finer interval merged into buckets of `intervalMs` aligned on the epoch (oldest first) */92export function aggregateBars(bars: readonly Bar[], intervalMs: number): Bar[] {93 const out: Bar[] = [];94 for (const b of bars) {95 const ts = Math.floor(b.ts / intervalMs) * intervalMs, last = out[out.length - 1];96 if (last && last.ts === ts) { last.h = Math.max(last.h, b.h); last.l = Math.min(last.l, b.l); last.c = b.c; last.v += b.v; }97 else out.push({ ts, o: b.o, h: b.h, l: b.l, c: b.c, v: b.v });98 }99 return out;100}101102/**103 * A live price (and optionally a traded size) applied to the series: updates the current candle, or opens the next one at the104 * previous close. A tick older than the last candle is ignored. Returns the same array when nothing changes.105 */106export function applyTick(bars: Bar[], intervalMs: number, price: number, ts: number, size = 0, cap = 600): Bar[] {107 if (!Number.isFinite(price) || !Number.isFinite(ts) || price <= 0) return bars;108 const bucket = Math.floor(ts / intervalMs) * intervalMs, last = bars[bars.length - 1];109 if (!last) return [{ ts: bucket, o: price, h: price, l: price, c: price, v: size }];110 if (bucket < last.ts) return bars;111 if (bucket === last.ts) {112 if (last.c === price && size === 0) return bars;113 return [...bars.slice(0, -1), { ...last, h: Math.max(last.h, price), l: Math.min(last.l, price), c: price, v: last.v + size }];114 }115 const next = [...bars, { ts: bucket, o: last.c, h: Math.max(last.c, price), l: Math.min(last.c, price), c: price, v: size }];116 return next.length > cap ? next.slice(next.length - cap) : next;117}118119/** simple moving average of the closes; null until `n` candles are available */120export function sma(bars: readonly Bar[], n: number): (number | null)[] {121 const out: (number | null)[] = [];122 let sum = 0;123 for (let i = 0; i < bars.length; i++) {124 sum += bars[i]!.c;125 if (i >= n) sum -= bars[i - n]!.c;126 out.push(i >= n - 1 ? sum / n : null);127 }128 return out;129}130