SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
8.8 KB · 188 lines typescript
Raw Blame History
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: "not_comparable" });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});133134describe("comparability, roles and coverage inputs", () => {135  const obsT = (source: string, value: number, ageMs: number, type: Observation["observationType"], rt: Observation["realtimeStatus"], rights: Observation["rightsStatus"] = "PUBLIC_ATTRIBUTED"): Observation => ({136    ...obs(source, value, ageMs, "LAST_PRICE", rt, "SOURCE"),137    observationType: type,138    rightsStatus: rights,139    observationId: `${source}-${type}-${value}`,140  });141  it("an official fixing is never compared to a live market value (no false divergence)", () => {142    const e = engine();143    e.ingest(obsT("a", 1.1725, 500, "TRADE", "REALTIME")); // Kraken EUR/USD live144    e.ingest(obsT("eod", 1.1592, 20 * 3_600_000, "OFFICIAL_FIX", "END_OF_DAY", "OFFICIAL_OPEN_DATA")); // ECB fixing yesterday145    const q = e.compute("crypto_btc_usd", "EURUSD", NOW)!;146    expect(q.price).toBe(1.1725);147    expect(q.comparability).toBe("LIVE");148    expect(q.dispersionBps).toBe(0);149    expect(q.contributions.find((c) => c.sourceId === "eod")).toMatchObject({ included: false, reason: "not_comparable" });150  });151  it("official fixing is canonical when no real live market exists; stablecoin proxies alone are INDICATIVE", () => {152    const e = engine();153    e.ingest(obsT("eod", 1.1592, 20 * 3_600_000, "OFFICIAL_FIX", "END_OF_DAY", "OFFICIAL_OPEN_DATA"));154    e.ingest(obsT("b", 1.1601, 500, "STABLECOIN_PROXY", "REALTIME"));155    let q = e.compute("crypto_btc_usd", "EURUSD", NOW)!;156    expect(q.comparability).toBe("FIX");157    expect(q.price).toBe(1.1592);158    const e2 = engine();159    e2.ingest(obsT("b", 1.1601, 500, "STABLECOIN_PROXY", "REALTIME"));160    q = e2.compute("crypto_btc_usd", "EURUSD", NOW)!;161    expect(q.realtimeStatus).toBe("INDICATIVE");162    expect(q.sourceCount).toBe(0); // proxies are not independent families163    expect(q.price).toBe(1.1601);164  });165  it("proxies confirm a real market without setting the price; validators never vote", () => {166    const e = engine();167    e.ingest(obsT("a", 100, 300, "TRADE", "REALTIME"));168    e.ingest(obsT("b", 100.02, 300, "STABLECOIN_PROXY", "REALTIME"));169    e.ingest(obsT("c", 100.01, 300, "TRADE", "DELAYED", "PUBLIC_RESTRICTED_REDISTRIBUTION"));170    const q = e.compute("crypto_btc_usd", "EURUSD", NOW)!;171    expect(q.price).toBe(100);172    expect(q.sourceCount).toBe(1);173    expect(q.proxyCount).toBe(1);174    expect(q.validatorCount).toBe(1);175    expect(q.contributions.find((c) => c.sourceId === "c")).toMatchObject({ included: false, reason: "validation_only" });176    expect(q.contributions.find((c) => c.sourceId === "c")!.deltaBps).toBeCloseTo(1, 0);177    expect(q.rightsStatus).toBe("PUBLIC_ATTRIBUTED"); // restricted validator does not taint the canonical value178  });179  it("same-class values far apart in time are temporal mismatches, not divergence", () => {180    const e = engine();181    e.ingest(obsT("h", 91.01, 8 * 86_400_000, "EOD_CLOSE", "END_OF_DAY", "LICENSED")); // last week's close182    e.ingest(obsT("g", 95.5, 20 * 3_600_000, "EOD_CLOSE", "END_OF_DAY", "LICENSED")); // yesterday's close183    const q = e.compute("crypto_btc_usd", "CL=F", NOW)!;184    expect(q.price).toBe(95.5);185    expect(q.contributions.find((c) => c.sourceId === "h")).toMatchObject({ included: false, reason: "temporal_mismatch" });186  });187});188