SPB Git

spb/market-atlas

Public
5.6 KB · 133 lines typescript
1import { describe, expect, it } from "vitest";2import type { Observation } from "@market-atlas/market-model";3import { ConsensusEngine, weightedMedian } from "./consensus.js";45const NOW = 1_789_200_000_000;6const profiles: Record<string, { family: string | null; reliability: number; isOfficial: boolean }> = {7  a: { family: "fa", reliability: 0.9, isOfficial: false },8  b: { family: "fb", reliability: 0.9, isOfficial: false },9  c: { family: "fc", reliability: 0.9, isOfficial: false },10  d: { family: "fd", reliability: 0.9, isOfficial: false },11  e: { family: "fa", reliability: 0.9, isOfficial: false }, // same upstream as a12  eod: { family: "official", reliability: 0.95, isOfficial: true },13};14const engine = () => new ConsensusEngine((id) => ({ ...(profiles[id] ?? { family: null, reliability: 0.6, isOfficial: false }), realtimeStatus: "REALTIME" }));1516function obs(source: string, value: number, ageMs = 500, field: Observation["field"] = "LAST_PRICE", rt: Observation["realtimeStatus"] = "REALTIME", trust: Observation["timestampTrust"] = "EXCHANGE"): Observation {17  return {18    observationId: `${source}-${field}-${value}-${ageMs}`,19    instrumentId: "crypto_btc_usd",20    symbol: "BTC-USD",21    field,22    value,23    currency: "USD",24    sourceTimestamp: NOW - ageMs,25    timestampTrust: trust,26    rightsStatus: "PUBLIC_ATTRIBUTED",27    realtimeStatus: rt,28    sourceId: source,29    connectorId: `${source}-ws`,30    receivedAt: NOW - ageMs + 50,31    latencyMs: 50,32    rawRef: null,33    normalizerVersion: "1.0",34  };35}3637describe("consensus engine", () => {38  it("all sources agree → canonical price with high confidence", () => {39    const e = engine();40    for (const [s, v] of [["a", 100], ["b", 100], ["c", 100]] as const) e.ingest(obs(s, v));41    const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!;42    expect(q.price).toBe(100);43    expect(q.sourceCount).toBe(3);44    expect(q.dispersionBps).toBe(0);45    expect(q.confidence).toBeGreaterThan(0.85);46    expect(q.confidence).toBeLessThanOrEqual(0.995);47  });48  it("one stale source is excluded and labelled", () => {49    const e = engine();50    e.ingest(obs("a", 100));51    e.ingest(obs("b", 100.1));52    e.ingest(obs("c", 90, 60_000)); // 60 s old realtime → stale53    const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!;54    expect(q.price).toBeGreaterThanOrEqual(100);55    expect(q.contributions.find((c) => c.sourceId === "c")).toMatchObject({ included: false, reason: "stale" });56    expect(q.sourceCount).toBe(2);57  });58  it("one extreme outlier among ≥3 is rejected", () => {59    const e = engine();60    e.ingest(obs("a", 100));61    e.ingest(obs("b", 100.2));62    e.ingest(obs("c", 99.9));63    e.ingest(obs("d", 130));64    const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!;65    expect(q.price).toBeLessThan(101);66    expect(q.contributions.find((c) => c.sourceId === "d")).toMatchObject({ included: false, reason: "outlier" });67  });68  it("two source clusters disagree → low confidence, large dispersion", () => {69    const e = engine();70    e.ingest(obs("a", 100));71    e.ingest(obs("b", 101.5));72    const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!;73    expect(q.dispersionBps).toBeGreaterThan(100);74    expect(q.confidence).toBeLessThan(0.7);75  });76  it("only one source available → modest confidence, count 1", () => {77    const e = engine();78    e.ingest(obs("a", 100));79    const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!;80    expect(q.price).toBe(100);81    expect(q.sourceCount).toBe(1);82    expect(q.confidence).toBeLessThan(0.85);83  });84  it("sources sharing an upstream family count once", () => {85    const e = engine();86    e.ingest(obs("a", 100));87    e.ingest(obs("e", 100));88    const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!;89    expect(q.sourceCount).toBe(1);90  });91  it("out-of-order older observation does not overwrite the newer one", () => {92    const e = engine();93    e.ingest(obs("a", 100, 100));94    e.ingest(obs("a", 90, 5000));95    expect(e.compute("crypto_btc_usd", "BTC-USD", NOW)!.price).toBe(100);96  });97  it("end-of-day value is superseded by a live source, but used when alone", () => {98    const e = engine();99    e.ingest(obs("eod", 95, 3_600_000, "LAST_PRICE", "END_OF_DAY", "SOURCE"));100    expect(e.compute("crypto_btc_usd", "BTC-USD", NOW)!.price).toBe(95);101    expect(e.compute("crypto_btc_usd", "BTC-USD", NOW)!.realtimeStatus).toBe("END_OF_DAY");102    e.ingest(obs("a", 100));103    const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!;104    expect(q.price).toBe(100);105    expect(q.realtimeStatus).toBe("REALTIME");106    expect(q.contributions.find((c) => c.sourceId === "eod")).toMatchObject({ included: false, reason: "superseded_by_live" });107  });108  it("market closed / everything stale → STALE status with last known value and zero confidence", () => {109    const e = engine();110    e.ingest(obs("a", 100, 10 * 60_000));111    const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!;112    expect(q.realtimeStatus).toBe("STALE");113    expect(q.price).toBe(100);114    expect(q.confidence).toBe(0);115  });116  it("derives change from previous close and tracks session high/low", () => {117    const e = engine();118    e.ingest(obs("a", 100));119    e.ingest(obs("a", 95, 500, "PREVIOUS_CLOSE"));120    let q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!;121    expect(q.change).toBeCloseTo(5);122    expect(q.changePercent).toBeCloseTo(5.263, 2);123    e.ingest(obs("a", 102, 100));124    q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!;125    expect(q.sessionHigh).toBe(102);126    expect(q.sessionLow).toBe(100);127  });128  it("weighted median", () => {129    expect(weightedMedian([{ v: 1, w: 1 }, { v: 2, w: 1 }, { v: 100, w: 1 }])).toBe(2);130    expect(weightedMedian([{ v: 1, w: 10 }, { v: 2, w: 1 }, { v: 100, w: 1 }])).toBe(1);131  });132});133
Files · 51f65b781db05eaf56fb220140642700827e9034
Menu

Theme