SPB Git

spb/market-atlas

Public

Source Mesh v2: observation types & comparability classes, validator/proxy roles, coverage engine + expansion queue, statistical lineage, 8 new connectors (Bitstamp, Gemini, Bitfinex, Bybit, Gate, Crypto.com, KuCoin, Nasdaq validator), live keyless FX (Kraken fiat pairs, stablecoin proxies)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 13, 2026) parent e4eb788 Browse files

60 changed files +3,739 −247

60 changed files
modified apps/api/src/api/public-view.ts +4 −0
@@ -48,6 +48,10 @@ export function publicQuote(q: CanonicalQuote | undefined, inst?: Instrument, ma
48 ask: v(q.ask), 48 ask: v(q.ask),
49 currency: q.currency, 49 currency: q.currency,
50 source_count: q.sourceCount, 50 source_count: q.sourceCount,
51+ observation_count: q.observationCount,
52+ proxy_count: q.proxyCount,
53+ validator_count: q.validatorCount,
54+ comparability: q.comparability,
51 dispersion_bps: q.dispersionBps, 55 dispersion_bps: q.dispersionBps,
52 confidence: q.confidence, 56 confidence: q.confidence,
53 freshness_ms: q.freshnessMs == null ? null : q.freshnessMs + age, 57 freshness_ms: q.freshnessMs == null ? null : q.freshnessMs + age,
modified apps/api/src/api/routes/admin.ts +3 −0
@@ -10,6 +10,7 @@ import { observationWriter } from "../../core/observations.js";
10 import { assertPublicUrl, redactObject } from "@market-atlas/connector-sdk"; 10 import { assertPublicUrl, redactObject } from "@market-atlas/connector-sdk";
11 import { ApiError, envelope, notFound } from "../server.js"; 11 import { ApiError, envelope, notFound } from "../server.js";
12 import { runDiscovery } from "../../discovery/probe.js"; 12 import { runDiscovery } from "../../discovery/probe.js";
13+import { lineage } from "../../core/lineage.js";
13 14
14 function requireAdmin(req: FastifyRequest) { 15 function requireAdmin(req: FastifyRequest) {
15 const token = req.headers["x-ma-admin-token"]; 16 const token = req.headers["x-ma-admin-token"];
@@ -128,6 +129,8 @@ export async function registerAdminRoutes(app: FastifyInstance) {
128 } 129 }
129 }); 130 });
130 131
132+ app.get("/v1/admin/lineage", async () => envelope(lineage.pairs()));
133+
131 app.get("/v1/admin/divergence", async () => { 134 app.get("/v1/admin/divergence", async () => {
132 const r = await pool.query("select id, ts, instrument_ids, title, data from market_events where type = 'SOURCE_DIVERGENCE' order by ts desc limit 100"); 135 const r = await pool.query("select id, ts, instrument_ids, title, data from market_events where type = 'SOURCE_DIVERGENCE' order by ts desc limit 100");
133 return envelope(r.rows); 136 return envelope(r.rows);
modified apps/api/src/api/routes/markets.ts +23 −1
@@ -1,6 +1,6 @@
1 import type { FastifyInstance } from "fastify"; 1 import type { FastifyInstance } from "fastify";
2 import { z } from "zod"; 2 import { z } from "zod";
3−import { PUBLICLY_REDISTRIBUTABLE, type AssetClass, type Instrument } from "@market-atlas/market-model"; 3+import { ASSET_CLASSES as ASSET_CLASSES_LIST, PUBLICLY_REDISTRIBUTABLE, type AssetClass, type Instrument } from "@market-atlas/market-model";
4 import { pool } from "../../db/pool.js"; 4 import { pool } from "../../db/pool.js";
5 import { calendar } from "../../core/calendar.js"; 5 import { calendar } from "../../core/calendar.js";
6 import { instruments } from "../../core/instruments.js"; 6 import { instruments } from "../../core/instruments.js";
@@ -8,6 +8,9 @@ import { quoteStore } from "../../core/quotes.js";
8 import { badRequest, envelope, notFound } from "../server.js"; 8 import { badRequest, envelope, notFound } from "../server.js";
9 import { publicInstrument, publicQuote } from "../public-view.js"; 9 import { publicInstrument, publicQuote } from "../public-view.js";
10 import { rowToEvent, round } from "./public.js"; 10 import { rowToEvent, round } from "./public.js";
11+import { coverageAll, coverageOf, coverageSummary, expansionQueue } from "../../core/coverage.js";
12+import { lineage } from "../../core/lineage.js";
13+import { resolveInstrument } from "./public.js";
11 14
12 const quoted = (pred: (i: Instrument) => boolean) => 15 const quoted = (pred: (i: Instrument) => boolean) =>
13 instruments 16 instruments
@@ -26,6 +29,25 @@ function featured(cls: AssetClass, n: number) {
26 } 29 }
27 30
28 export async function registerMarketRoutes(app: FastifyInstance) { 31 export async function registerMarketRoutes(app: FastifyInstance) {
32+ /** Source Coverage Engine: redundancy histogram, tier attainment, expansion queue. */
33+ app.get("/v1/coverage", async (req) => {
34+ const p = z.object({ limit: z.coerce.number().int().min(1).max(500).default(50), asset_class: z.enum(ASSET_CLASSES_LIST).optional(), tier: z.enum(["A", "B", "C", "D"]).optional() }).parse(req.query);
35+ let list = coverageAll();
36+ if (p.asset_class) list = list.filter((c) => c.assetClass === p.asset_class);
37+ if (p.tier) list = list.filter((c) => c.tier === p.tier);
38+ return envelope({
39+ summary: coverageSummary(list),
40+ targets: { A: "≥3 independent families, 5 observations (majors)", B: "≥2 families, 3 observations (liquid)", C: "1 family, 2 observations (long tail)", D: "official single source acceptable (reference rates)" },
41+ queue: expansionQueue(list, p.limit),
42+ lineage: lineage.pairs().filter((x) => x.likelySharedUpstream),
43+ });
44+ });
45+ app.get<{ Params: { id: string } }>("/v1/coverage/:id", async (req) => {
46+ const inst = resolveInstrument(req.params.id);
47+ if (!inst) throw notFound("instrument");
48+ return envelope(coverageOf(inst, quoteStore.get(inst.id)));
49+ });
50+
29 /** Global market overview for the homepage and /markets. */ 51 /** Global market overview for the homepage and /markets. */
30 app.get("/v1/markets", async () => { 52 app.get("/v1/markets", async () => {
31 const exchanges = calendar.list().map((e) => ({ ...e, status: calendar.status(e.id) })); 53 const exchanges = calendar.list().map((e) => ({ ...e, status: calendar.status(e.id) }));
modified apps/api/src/api/routes/public.ts +14 −1
@@ -13,6 +13,8 @@ import { pipeline } from "../../core/pipeline.js";
13 import { badRequest, envelope, notFound } from "../server.js"; 13 import { badRequest, envelope, notFound } from "../server.js";
14 import { publicInstrument, publicQuote } from "../public-view.js"; 14 import { publicInstrument, publicQuote } from "../public-view.js";
15 import { SOURCES } from "@market-atlas/connectors"; 15 import { SOURCES } from "@market-atlas/connectors";
16+import { coverageOf, coverageAll, coverageSummary } from "../../core/coverage.js";
17+import { lineage } from "../../core/lineage.js";
16 18
17 const q = <T extends z.ZodTypeAny>(schema: T, input: unknown): z.infer<T> => { 19 const q = <T extends z.ZodTypeAny>(schema: T, input: unknown): z.infer<T> => {
18 const r = schema.safeParse(input); 20 const r = schema.safeParse(input);
@@ -111,6 +113,7 @@ export async function registerPublicRoutes(app: FastifyInstance) {
111 events_per_min: round(telemetry.rate("events_total") * 60, 2), 113 events_per_min: round(telemetry.rate("events_total") * 60, 2),
112 filings_24h: filings.rows[0]?.n ?? 0, 114 filings_24h: filings.rows[0]?.n ?? 0,
113 median_freshness_ms: median(quoted.filter((x) => x.realtimeStatus === "REALTIME").map((x) => (x.freshnessMs ?? 0) + (Date.now() - x.updatedAt))), 115 median_freshness_ms: median(quoted.filter((x) => x.realtimeStatus === "REALTIME").map((x) => (x.freshnessMs ?? 0) + (Date.now() - x.updatedAt))),
116+ coverage: coverageSummary(coverageAll()),
114 stream_clients: telemetry.counter("stream_clients_connected") - telemetry.counter("stream_clients_disconnected"), 117 stream_clients: telemetry.counter("stream_clients_connected") - telemetry.counter("stream_clients_disconnected"),
115 uptime_s: Math.round(process.uptime()), 118 uptime_s: Math.round(process.uptime()),
116 }); 119 });
@@ -205,12 +208,18 @@ export async function registerPublicRoutes(app: FastifyInstance) {
205 confidence: quote.confidence, 208 confidence: quote.confidence,
206 dispersion_bps: quote.dispersionBps, 209 dispersion_bps: quote.dispersionBps,
207 independent_sources: quote.sourceCount, 210 independent_sources: quote.sourceCount,
211+ observations: quote.observationCount,
212+ proxy_confirmations: quote.proxyCount,
213+ validator_confirmations: quote.validatorCount,
214+ comparability: quote.comparability,
215+ coverage: coverageOf(inst, quote),
216+ shared_upstream_pairs: lineage.pairs().filter((p) => p.likelySharedUpstream && quote.contributions.some((c) => c.sourceId === p.sourceA || c.sourceId === p.sourceB)),
208 freshness_ms: quote.freshnessMs, 217 freshness_ms: quote.freshnessMs,
209 realtime_status: quote.realtimeStatus, 218 realtime_status: quote.realtimeStatus,
210 rights_status: quote.rightsStatus, 219 rights_status: quote.rightsStatus,
211 consensus_version: quote.consensusVersion, 220 consensus_version: quote.consensusVersion,
212 computed_at: new Date(quote.updatedAt).toISOString(), 221 computed_at: new Date(quote.updatedAt).toISOString(),
213− method: "weighted median over fresh observations; one vote per upstream source family; outliers > 2% from the median excluded; weights = source reliability × timestamp quality × real-time class × freshness decay", 222+ method: "comparable observations only (same class: live / official fix / end-of-day, close in time); weighted median over fresh real-market observations, one vote per upstream source family (declared or statistically inferred), liquidity-aware for crypto, official sources dominate for rates; outliers > 2% excluded; stablecoin/derived proxies and restricted-rights sources confirm but never set the value",
214 contributions: quote.contributions.map((c) => ({ 223 contributions: quote.contributions.map((c) => ({
215 source_id: c.sourceId, 224 source_id: c.sourceId,
216 source: sourceMeta[c.sourceId] ?? null, 225 source: sourceMeta[c.sourceId] ?? null,
@@ -222,6 +231,8 @@ export async function registerPublicRoutes(app: FastifyInstance) {
222 weight: c.weight, 231 weight: c.weight,
223 included: c.included, 232 included: c.included,
224 reason: c.reason ?? null, 233 reason: c.reason ?? null,
234+ observation_type: c.observationType,
235+ delta_bps: c.deltaBps ?? null,
225 realtime_status: c.realtimeStatus, 236 realtime_status: c.realtimeStatus,
226 rights_status: c.rightsStatus, 237 rights_status: c.rightsStatus,
227 reliability_score: health.sourceReliability(c.sourceId), 238 reliability_score: health.sourceReliability(c.sourceId),
@@ -361,6 +372,8 @@ export async function registerPublicRoutes(app: FastifyInstance) {
361 last_observation: last ? new Date(last).toISOString() : null, 372 last_observation: last ? new Date(last).toISOString() : null,
362 reliability_score: snaps.map((x) => x.reliabilityScore).filter((x): x is number => x != null).sort((a, b) => b - a)[0] ?? null, 373 reliability_score: snaps.map((x) => x.reliabilityScore).filter((x): x is number => x != null).sort((a, b) => b - a)[0] ?? null,
363 asset_classes: [...new Set(conns.flatMap((c) => c.def.metadata.assetClasses))], 374 asset_classes: [...new Set(conns.flatMap((c) => c.def.metadata.assetClasses))],
375+ likely_shared_upstream_with: lineage.pairs().filter((p) => p.likelySharedUpstream && (p.sourceA === s.id || p.sourceB === s.id)).map((p) => (p.sourceA === s.id ? p.sourceB : p.sourceA)),
376+ role: conns.length && !PUBLICLY_REDISTRIBUTABLE.has(s.rights_status) ? "validator" : "contributor",
364 description: conns[0]?.def.metadata.description ?? null, 377 description: conns[0]?.def.metadata.description ?? null,
365 rights_notes: conns[0]?.def.metadata.rightsNotes ?? null, 378 rights_notes: conns[0]?.def.metadata.rightsNotes ?? null,
366 terms_url: conns[0]?.def.metadata.termsUrl ?? null, 379 terms_url: conns[0]?.def.metadata.termsUrl ?? null,
modified apps/api/src/core/consensus.test.ts +56 −1
@@ -103,7 +103,7 @@ describe("consensus engine", () => {
103 const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!; 103 const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!;
104 expect(q.price).toBe(100); 104 expect(q.price).toBe(100);
105 expect(q.realtimeStatus).toBe("REALTIME"); 105 expect(q.realtimeStatus).toBe("REALTIME");
106− expect(q.contributions.find((c) => c.sourceId === "eod")).toMatchObject({ included: false, reason: "superseded_by_live" }); 106+ expect(q.contributions.find((c) => c.sourceId === "eod")).toMatchObject({ included: false, reason: "not_comparable" });
107 }); 107 });
108 it("market closed / everything stale → STALE status with last known value and zero confidence", () => { 108 it("market closed / everything stale → STALE status with last known value and zero confidence", () => {
109 const e = engine(); 109 const e = engine();
@@ -130,3 +130,58 @@ describe("consensus engine", () => {
130 expect(weightedMedian([{ v: 1, w: 10 }, { v: 2, w: 1 }, { v: 100, w: 1 }])).toBe(1); 130 expect(weightedMedian([{ v: 1, w: 10 }, { v: 2, w: 1 }, { v: 100, w: 1 }])).toBe(1);
131 }); 131 });
132 }); 132 });
133+
134+describe("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 live
144+ e.ingest(obsT("eod", 1.1592, 20 * 3_600_000, "OFFICIAL_FIX", "END_OF_DAY", "OFFICIAL_OPEN_DATA")); // ECB fixing yesterday
145+ 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 families
163+ 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 value
178+ });
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 close
182+ e.ingest(obsT("g", 95.5, 20 * 3_600_000, "EOD_CLOSE", "END_OF_DAY", "LICENSED")); // yesterday's close
183+ 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+});
modified apps/api/src/core/consensus.ts +173 −85
@@ -1,5 +1,5 @@
1−import type { CanonicalQuote, Observation, RealtimeStatus, RightsStatus, SourceContribution } from "@market-atlas/market-model"; 1+import type { AssetClass, CanonicalQuote, ComparabilityClass, Observation, ObservationType, RealtimeStatus, RightsStatus, SourceContribution } from "@market-atlas/market-model";
2−import { CANONICAL_VERSIONS, PUBLICLY_REDISTRIBUTABLE } from "@market-atlas/market-model"; 2+import { CANONICAL_VERSIONS, COMPARABILITY, PROXY_TYPES, PUBLICLY_REDISTRIBUTABLE } from "@market-atlas/market-model";
3 import { config } from "../config.js"; 3 import { config } from "../config.js";
4 4
5 export interface SourceProfile { 5 export interface SourceProfile {
@@ -13,7 +13,7 @@ interface Latest {
13 obs: Observation; 13 obs: Observation;
14 } 14 }
15 15
16−/** Fresh window per realtime class: realtime ticks age out fast, EOD values live a day+. */ 16+/** Fresh window per realtime class: realtime ticks age out fast, EOD values live for days. */
17 const FRESH_WINDOW_MS: Record<RealtimeStatus, number> = { 17 const FRESH_WINDOW_MS: Record<RealtimeStatus, number> = {
18 REALTIME: config.consensusFreshWindowMs, 18 REALTIME: config.consensusFreshWindowMs,
19 DELAYED: 30 * 60_000, 19 DELAYED: 30 * 60_000,
@@ -24,23 +24,54 @@ const FRESH_WINDOW_MS: Record<RealtimeStatus, number> = {
24 }; 24 };
25 25
26 const REALTIME_RANK: Record<RealtimeStatus, number> = { REALTIME: 4, DELAYED: 3, INDICATIVE: 2, END_OF_DAY: 1, UNKNOWN: 0, STALE: -1 }; 26 const REALTIME_RANK: Record<RealtimeStatus, number> = { REALTIME: 4, DELAYED: 3, INDICATIVE: 2, END_OF_DAY: 1, UNKNOWN: 0, STALE: -1 };
27+/** Same-class observations must be close in time to be compared: live within 2 minutes, fixings/closes on the same UTC day. */
28+const LIVE_TOLERANCE_MS = 120_000;
29+
30+/** Infer the observation type when a connector did not declare one. */
31+export function inferObservationType(o: Pick<Observation, "realtimeStatus" | "observationType" | "field">): ObservationType {
32+ if (o.observationType) return o.observationType;
33+ if (o.field === "PREVIOUS_CLOSE") return "EOD_CLOSE";
34+ switch (o.realtimeStatus) {
35+ case "END_OF_DAY":
36+ return "EOD_CLOSE";
37+ case "INDICATIVE":
38+ return "INDICATIVE";
39+ default:
40+ return "TRADE";
41+ }
42+}
43+
44+interface Candidate {
45+ value: number;
46+ weight: number;
47+ family: string;
48+ obs: Observation;
49+ type: ObservationType;
50+ proxy: boolean;
51+ validator: boolean;
52+ contrib: SourceContribution;
53+}
27 54
28 /** 55 /**
29− * Per-instrument consensus. Keeps the latest observation of each (source, field), computes a 56+ * Per-instrument consensus. Keeps the latest observation of each (source, field), keeps only
30− * weighted median price over fresh observations, discounts sources sharing an upstream family, 57+ * *comparable* observations (same class — live / official fix / end-of-day — and close in time),
31− * flags outliers, and exposes the full contribution list ("Why this price?"). 58+ * computes a weighted median (one vote per upstream family, liquidity-aware for crypto), rejects
59+ * outliers, uses restricted-rights sources and proxies as confirmations only, and exposes the full
60+ * contribution list ("Why this price?").
32 */ 61 */
33 export class ConsensusEngine { 62 export class ConsensusEngine {
34 private latest = new Map<string, Map<string, Latest>>(); // instrumentId -> `${sourceId}|${field}` -> latest 63 private latest = new Map<string, Map<string, Latest>>(); // instrumentId -> `${sourceId}|${field}` -> latest
35 private session = new Map<string, { high: number; low: number; startedAt: number }>(); 64 private session = new Map<string, { high: number; low: number; startedAt: number }>();
36 65
37 /** 66 /**
38− * @param profiles source reliability/family lookup 67+ * @param profiles source reliability/family lookup
39− * @param marketOpen true/false when the instrument's venue has a session calendar, null for continuous or unknown venues 68+ * @param marketOpen true/false when the instrument's venue has a session calendar, null for continuous or unknown venues
69+ * @param assetClassOf asset class of an instrument (liquidity weighting for crypto, official dominance for rates)
40 */ 70 */
41 constructor( 71 constructor(
42 private profiles: (sourceId: string) => SourceProfile, 72 private profiles: (sourceId: string) => SourceProfile,
43 private marketOpen: (instrumentId: string) => boolean | null = () => null, 73 private marketOpen: (instrumentId: string) => boolean | null = () => null,
74+ private assetClassOf: (instrumentId: string) => AssetClass | null = () => null,
44 ) {} 75 ) {}
45 76
46 ingest(o: Observation): void { 77 ingest(o: Observation): void {
@@ -78,12 +109,20 @@ export class ConsensusEngine {
78 return [...new Set([...m.keys()].map((k) => k.split("|")[0]!))]; 109 return [...new Set([...m.keys()].map((k) => k.split("|")[0]!))];
79 } 110 }
80 111
112+ /** Latest LAST_PRICE observation per source (for lineage inference and coverage). */
113+ latestPrices(instrumentId: string): Observation[] {
114+ const m = this.latest.get(instrumentId);
115+ if (!m) return [];
116+ return [...m.entries()].filter(([k]) => k.endsWith("|LAST_PRICE")).map(([, l]) => l.obs);
117+ }
118+
81 compute(instrumentId: string, symbol: string, now = Date.now()): CanonicalQuote | null { 119 compute(instrumentId: string, symbol: string, now = Date.now()): CanonicalQuote | null {
82 const m = this.latest.get(instrumentId); 120 const m = this.latest.get(instrumentId);
83 if (!m) return null; 121 if (!m) return null;
84 const closed = this.marketOpen(instrumentId) === false; 122 const closed = this.marketOpen(instrumentId) === false;
85− const price = this.aggregate(m, "LAST_PRICE", now, closed); 123+ const assetClass = this.assetClassOf(instrumentId);
86− const pick = (field: string) => this.aggregate(m, field, now, closed); 124+ const price = this.aggregate(m, "LAST_PRICE", now, closed, assetClass);
125+ const pick = (field: string) => this.aggregate(m, field, now, closed, assetClass);
87 const open = pick("OPEN"); 126 const open = pick("OPEN");
88 const high = pick("HIGH"); 127 const high = pick("HIGH");
89 const low = pick("LOW"); 128 const low = pick("LOW");
@@ -104,7 +143,6 @@ export class ConsensusEngine {
104 change = p - open.value; 143 change = p - open.value;
105 changePercent = (change / open.value) * 100; 144 changePercent = (change / open.value) * 100;
106 } 145 }
107− // Session high/low derived by Market Atlas itself.
108 let sess = this.session.get(instrumentId); 146 let sess = this.session.get(instrumentId);
109 if (p != null) { 147 if (p != null) {
110 if (!sess) { 148 if (!sess) {
@@ -135,6 +173,10 @@ export class ConsensusEngine {
135 ask: ask.value, 173 ask: ask.value,
136 currency, 174 currency,
137 sourceCount: price.independentFamilies, 175 sourceCount: price.independentFamilies,
176+ observationCount: price.observationCount,
177+ proxyCount: price.proxyCount,
178+ validatorCount: price.validatorCount,
179+ comparability: price.comparability,
138 dispersionBps: price.dispersionBps, 180 dispersionBps: price.dispersionBps,
139 confidence: price.confidence, 181 confidence: price.confidence,
140 freshnessMs: price.freshnessMs, 182 freshnessMs: price.freshnessMs,
@@ -149,121 +191,167 @@ export class ConsensusEngine {
149 }; 191 };
150 } 192 }
151 193
152− private aggregate(m: Map<string, Latest>, field: string, now: number, marketClosed = false) { 194+ private aggregate(m: Map<string, Latest>, field: string, now: number, marketClosed: boolean, assetClass: AssetClass | null) {
153 const contributions: SourceContribution[] = []; 195 const contributions: SourceContribution[] = [];
154− const candidates: Array<{ value: number; weight: number; family: string; obs: Observation }> = []; 196+ const fresh: Candidate[] = [];
197+ const empty = {
198+ value: null as number | null,
199+ confidence: 0,
200+ dispersionBps: null as number | null,
201+ independentFamilies: 0,
202+ observationCount: 0,
203+ proxyCount: 0,
204+ validatorCount: 0,
205+ comparability: null as ComparabilityClass | null,
206+ freshnessMs: null as number | null,
207+ realtimeStatus: "STALE" as RealtimeStatus,
208+ sourceTimestamp: null as number | null,
209+ contributions,
210+ };
211+
155 for (const [key, l] of m) { 212 for (const [key, l] of m) {
156 if (!key.endsWith(`|${field}`)) continue; 213 if (!key.endsWith(`|${field}`)) continue;
157 const o = l.obs; 214 const o = l.obs;
158 const prof = this.profiles(o.sourceId); 215 const prof = this.profiles(o.sourceId);
216+ const type = inferObservationType(o);
159 const eventTs = o.sourceTimestamp ?? o.receivedAt; 217 const eventTs = o.sourceTimestamp ?? o.receivedAt;
160 const ageMs = Math.max(0, now - eventTs); 218 const ageMs = Math.max(0, now - eventTs);
161 // While the venue is closed, the last session value is the truth for up to 4 days (long weekends). 219 // While the venue is closed, the last session value is the truth for up to 4 days (long weekends).
162 const window = marketClosed && (o.realtimeStatus === "REALTIME" || o.realtimeStatus === "DELAYED") ? 4 * 86_400_000 : (FRESH_WINDOW_MS[o.realtimeStatus] ?? 60_000); 220 const window = marketClosed && (o.realtimeStatus === "REALTIME" || o.realtimeStatus === "DELAYED") ? 4 * 86_400_000 : (FRESH_WINDOW_MS[o.realtimeStatus] ?? 60_000);
163− const fresh = ageMs <= window; 221+ const isFresh = ageMs <= window;
164 const base = 0.35 + 0.65 * prof.reliability; 222 const base = 0.35 + 0.65 * prof.reliability;
165 const tsQuality = o.timestampTrust === "EXCHANGE" ? 1 : o.timestampTrust === "SOURCE" ? 0.9 : 0.75; 223 const tsQuality = o.timestampTrust === "EXCHANGE" ? 1 : o.timestampTrust === "SOURCE" ? 0.9 : 0.75;
166 const rtQuality = o.realtimeStatus === "REALTIME" ? 1 : o.realtimeStatus === "DELAYED" ? 0.6 : o.realtimeStatus === "INDICATIVE" ? 0.5 : 0.4; 224 const rtQuality = o.realtimeStatus === "REALTIME" ? 1 : o.realtimeStatus === "DELAYED" ? 0.6 : o.realtimeStatus === "INDICATIVE" ? 0.5 : 0.4;
167− const official = prof.isOfficial ? 1.15 : 1; 225+ const official = prof.isOfficial ? (assetClass === "TREASURY" || assetClass === "INTEREST_RATE" || assetClass === "BOND" ? 1.6 : 1.15) : 1;
168− const decay = fresh ? Math.exp(-ageMs / Math.max(1000, window)) : 0; 226+ const decay = isFresh ? Math.exp(-ageMs / Math.max(1000, window)) : 0;
169 const weight = base * tsQuality * rtQuality * official * (0.4 + 0.6 * decay) * (o.confidence ?? 1); 227 const weight = base * tsQuality * rtQuality * official * (0.4 + 0.6 * decay) * (o.confidence ?? 1);
170− const c: SourceContribution = { 228+ const contrib: SourceContribution = {
171 sourceId: o.sourceId, 229 sourceId: o.sourceId,
172 connectorId: o.connectorId, 230 connectorId: o.connectorId,
173 value: o.value, 231 value: o.value,
174 sourceTimestamp: o.sourceTimestamp, 232 sourceTimestamp: o.sourceTimestamp,
175 receivedAt: o.receivedAt, 233 receivedAt: o.receivedAt,
176 ageMs, 234 ageMs,
177− weight: fresh ? round(weight, 4) : 0, 235+ weight: 0,
178− included: fresh, 236+ included: false,
179− realtimeStatus: fresh ? o.realtimeStatus : "STALE", 237+ observationType: type,
238+ deltaBps: null,
239+ realtimeStatus: isFresh ? o.realtimeStatus : "STALE",
180 rightsStatus: o.rightsStatus, 240 rightsStatus: o.rightsStatus,
181− ...(fresh ? {} : { reason: "stale" }), 241+ ...(isFresh ? {} : { reason: "stale" }),
182 }; 242 };
183− contributions.push(c); 243+ contributions.push(contrib);
184− if (fresh) candidates.push({ value: o.value, weight, family: prof.family ?? o.sourceId, obs: o }); 244+ if (isFresh) fresh.push({ value: o.value, weight, family: prof.family ?? o.sourceId, obs: o, type, proxy: PROXY_TYPES.has(type), validator: !PUBLICLY_REDISTRIBUTABLE.has(o.rightsStatus), contrib });
185 } 245 }
186− if (!candidates.length) { 246+ if (!fresh.length) {
187 // Fall back to the most recent stale value so the UI can show "last known", clearly labelled STALE. 247 // Fall back to the most recent stale value so the UI can show "last known", clearly labelled STALE.
188− const newest = contributions.sort((a, b) => a.ageMs - b.ageMs)[0]; 248+ const newest = [...contributions].sort((a, b) => a.ageMs - b.ageMs)[0];
189− return { 249+ return { ...empty, value: newest?.value ?? null, freshnessMs: newest?.ageMs ?? null, sourceTimestamp: newest?.sourceTimestamp ?? null, contributions: contributions.sort((a, b) => a.ageMs - b.ageMs) };
190− value: newest?.value ?? null,
191− confidence: 0,
192− dispersionBps: null,
193− independentFamilies: 0,
194− freshnessMs: newest?.ageMs ?? null,
195− realtimeStatus: "STALE" as RealtimeStatus,
196− sourceTimestamp: newest?.sourceTimestamp ?? null,
197− contributions,
198− };
199 } 250 }
200− // Tiering: when live (REALTIME/DELAYED) observations exist, end-of-day/indicative values are superseded. 251+
201− const bestRank = Math.max(...candidates.map((c) => REALTIME_RANK[c.obs.realtimeStatus])); 252+ // 1. Comparability class: live values from real markets win, then official fixings, then end-of-day closes,
202− if (bestRank >= 3) { 253+ // then live proxies alone (stablecoin markets over a weekend). Other classes are reported, never mixed.
203− for (const c of candidates) { 254+ const cls = (c: Candidate) => COMPARABILITY[c.type];
204− if (REALTIME_RANK[c.obs.realtimeStatus] <= 2) { 255+ const hasRealLive = fresh.some((c) => cls(c) === "LIVE" && !c.proxy);
205− const contrib = contributions.find((x) => x.sourceId === c.obs.sourceId && x.included); 256+ const chosen: ComparabilityClass = hasRealLive ? "LIVE" : fresh.some((c) => cls(c) === "FIX") ? "FIX" : fresh.some((c) => cls(c) === "EOD") ? "EOD" : "LIVE";
206− if (contrib) { 257+ let pool = fresh.filter((c) => cls(c) === chosen);
207− contrib.included = false; 258+ for (const c of fresh) if (cls(c) !== chosen) exclude(c, "not_comparable");
208− contrib.reason = "superseded_by_live"; 259+
209− contrib.weight = 0; 260+ // 2. Temporal tolerance inside the class.
210− } 261+ const tsOf = (c: Candidate) => c.obs.sourceTimestamp ?? c.obs.receivedAt;
211− } 262+ const freshestTs = Math.max(...pool.map(tsOf));
263+ pool = pool.filter((c) => {
264+ const ok = chosen === "LIVE" ? freshestTs - tsOf(c) <= LIVE_TOLERANCE_MS : sameUtcDay(tsOf(c), freshestTs);
265+ if (!ok) exclude(c, "temporal_mismatch");
266+ return ok;
267+ });
268+
269+ // 3. Roles: restricted-rights sources validate only; proxies confirm with reduced weight when real sources exist.
270+ const validators = pool.filter((c) => c.validator);
271+ for (const c of validators) exclude(c, "validation_only");
272+ let voters = pool.filter((c) => !c.validator);
273+ const realVoters = voters.filter((c) => !c.proxy);
274+ const proxies = voters.filter((c) => c.proxy);
275+ if (realVoters.length) for (const c of proxies) c.weight *= 0.35;
276+
277+ // 4. One vote per upstream family (the freshest keeps its weight, others are discounted).
278+ const seenFamily = new Set<string>();
279+ for (const c of [...voters].sort((a, b) => tsOf(b) - tsOf(a))) {
280+ if (seenFamily.has(c.family)) c.weight *= 0.25;
281+ else seenFamily.add(c.family);
282+ }
283+ // 5. Crypto: liquidity-aware — a venue's weight scales with its share of the 24 h volume among voters.
284+ if (assetClass === "CRYPTO" && voters.length > 1) {
285+ const vols = new Map<string, number>();
286+ for (const c of voters) {
287+ const v = m.get(`${c.obs.sourceId}|VOLUME`)?.obs.value;
288+ if (v != null && v > 0) vols.set(c.obs.sourceId, v * c.value); // notional
212 } 289 }
213− const live = candidates.filter((c) => REALTIME_RANK[c.obs.realtimeStatus] >= 3); 290+ const max = Math.max(0, ...vols.values());
214− candidates.length = 0; 291+ if (max > 0) for (const c of voters) c.weight *= 0.6 + 0.8 * ((vols.get(c.obs.sourceId) ?? 0) / max);
215− candidates.push(...live);
216 } 292 }
217− // Family de-duplication: one vote per upstream family (the freshest), others get a small weight. 293+
218− const seenFamily = new Map<string, number>(); 294+ // 6. Outlier rejection (> 2 % from the weighted median of real voters, when at least 3 voters).
219− for (const c of candidates) { 295+ const reference = realVoters.length ? realVoters : voters;
220− const prior = seenFamily.get(c.family); 296+ let median = weightedMedian(reference.map((c) => ({ v: c.value, w: c.weight })));
221− if (prior == null) seenFamily.set(c.family, c.weight); 297+ if (voters.length >= 3 && median !== 0) {
222− else c.weight *= 0.25; 298+ const kept = voters.filter((c) => Math.abs(c.value - median) / Math.abs(median) <= 0.02);
299+ for (const c of voters) if (!kept.includes(c)) exclude(c, "outlier");
300+ if (kept.length) voters = kept;
301+ median = weightedMedian((kept.filter((c) => !c.proxy).length ? kept.filter((c) => !c.proxy) : kept).map((c) => ({ v: c.value, w: c.weight })));
223 } 302 }
224− const median = weightedMedian(candidates.map((c) => ({ v: c.value, w: c.weight }))); 303+ const finalVoters = voters;
225− // Outlier rejection: > 2% away from median with ≥ 3 candidates → excluded. 304+ for (const c of finalVoters) {
226− let usable = candidates; 305+ c.contrib.included = true;
227− if (candidates.length >= 3 && median !== 0) { 306+ c.contrib.weight = round(c.weight, 4);
228− usable = candidates.filter((c) => Math.abs(c.value - median) / Math.abs(median) <= 0.02);
229− for (const c of candidates) {
230− if (!usable.includes(c)) {
231− const contrib = contributions.find((x) => x.sourceId === c.obs.sourceId && x.included);
232− if (contrib) {
233− contrib.included = false;
234− contrib.reason = "outlier";
235− contrib.weight = 0;
236− }
237− }
238− }
239− if (!usable.length) usable = candidates;
240 } 307 }
241− const finalMedian = weightedMedian(usable.map((c) => ({ v: c.value, w: c.weight }))); 308+ // Deltas vs canonical for every fresh contribution (included or not).
242− const values = usable.map((c) => c.value); 309+ for (const c of fresh) c.contrib.deltaBps = median ? round(((c.value - median) / Math.abs(median)) * 10_000, 2) : null;
243− const spread = values.length > 1 ? (Math.max(...values) - Math.min(...values)) / Math.abs(finalMedian || 1) : 0; 310+
244− const dispersionBps = round(spread * 10_000, 2); 311+ const realIncluded = finalVoters.filter((c) => !c.proxy);
245− const families = new Set(usable.map((c) => c.family)).size; 312+ const spreadBasis = realIncluded.length ? realIncluded : finalVoters;
246− const newest = Math.min(...usable.map((c) => Math.max(0, now - (c.obs.sourceTimestamp ?? c.obs.receivedAt)))); 313+ const values = spreadBasis.map((c) => c.value);
247− const bestRt = usable.map((c) => c.obs.realtimeStatus).sort((a, b) => REALTIME_RANK[b] - REALTIME_RANK[a])[0] ?? "UNKNOWN"; 314+ const spread = values.length > 1 ? (Math.max(...values) - Math.min(...values)) / Math.abs(median || 1) : 0;
248− // Confidence: agreement (dispersion), redundancy (families), freshness. Never claims > 0.995. 315+ const families = new Set(realIncluded.map((c) => c.family)).size;
316+ const proxyCount = finalVoters.filter((c) => c.proxy && Math.abs(c.contrib.deltaBps ?? 1e9) <= 50).length;
317+ const validatorCount = validators.filter((c) => Math.abs(c.contrib.deltaBps ?? 1e9) <= 25).length;
318+ for (const c of validators) if (Math.abs(c.contrib.deltaBps ?? 0) > 50) c.contrib.reason = "validator_disagrees";
319+ const newest = Math.min(...finalVoters.map((c) => Math.max(0, now - tsOf(c))));
320+ let bestRt = finalVoters.map((c) => c.obs.realtimeStatus).sort((a, b) => REALTIME_RANK[b] - REALTIME_RANK[a])[0] ?? "UNKNOWN";
321+ if (chosen === "LIVE" && !realIncluded.length) bestRt = "INDICATIVE"; // proxies only
322+ // Confidence: agreement (dispersion), redundancy (independent families + confirmations), freshness, reliability. Never claims > 0.995.
249 const agreement = Math.max(0, 1 - Math.min(1, spread / 0.01)); // 1% spread → 0 323 const agreement = Math.max(0, 1 - Math.min(1, spread / 0.01)); // 1% spread → 0
250− const redundancy = 1 - Math.exp(-families / 2); // 1 fam → 0.39, 3 → 0.78, 5 → 0.92 324+ const redundancy = 1 - Math.exp(-(families + 0.5 * proxyCount + 0.5 * validatorCount) / 2);
251 const freshness = marketClosed ? 1 : Math.exp(-newest / 60_000); 325 const freshness = marketClosed ? 1 : Math.exp(-newest / 60_000);
252− const avgReliability = usable.reduce((a, c) => a + this.profiles(c.obs.sourceId).reliability, 0) / usable.length; 326+ const avgReliability = finalVoters.reduce((a, c) => a + this.profiles(c.obs.sourceId).reliability, 0) / finalVoters.length;
253 const confidence = round(Math.min(0.995, 0.15 + 0.35 * agreement + 0.25 * redundancy + 0.1 * freshness + 0.15 * avgReliability), 3); 327 const confidence = round(Math.min(0.995, 0.15 + 0.35 * agreement + 0.25 * redundancy + 0.1 * freshness + 0.15 * avgReliability), 3);
254 return { 328 return {
255− value: finalMedian, 329+ value: median,
256 confidence, 330 confidence,
257− dispersionBps, 331+ dispersionBps: round(spread * 10_000, 2),
258 independentFamilies: families, 332 independentFamilies: families,
333+ observationCount: fresh.length,
334+ proxyCount,
335+ validatorCount,
336+ comparability: chosen,
259 freshnessMs: Math.round(newest), 337 freshnessMs: Math.round(newest),
260 realtimeStatus: bestRt, 338 realtimeStatus: bestRt,
261− sourceTimestamp: usable.map((c) => c.obs.sourceTimestamp).filter((t): t is number => t != null).sort((a, b) => b - a)[0] ?? null, 339+ sourceTimestamp: finalVoters.map((c) => c.obs.sourceTimestamp).filter((t): t is number => t != null).sort((a, b) => b - a)[0] ?? null,
262 contributions: contributions.sort((a, b) => Number(b.included) - Number(a.included) || a.ageMs - b.ageMs), 340 contributions: contributions.sort((a, b) => Number(b.included) - Number(a.included) || a.ageMs - b.ageMs),
263 }; 341 };
264 } 342 }
265 } 343 }
266 344
345+function exclude(c: Candidate, reason: string) {
346+ c.contrib.included = false;
347+ c.contrib.weight = 0;
348+ if (!c.contrib.reason) c.contrib.reason = reason;
349+}
350+
351+function sameUtcDay(a: number, b: number): boolean {
352+ return Math.floor(a / 86_400_000) === Math.floor(b / 86_400_000);
353+}
354+
267 export function weightedMedian(items: Array<{ v: number; w: number }>): number { 355 export function weightedMedian(items: Array<{ v: number; w: number }>): number {
268 if (!items.length) return NaN; 356 if (!items.length) return NaN;
269 const sorted = [...items].sort((a, b) => a.v - b.v); 357 const sorted = [...items].sort((a, b) => a.v - b.v);
added apps/api/src/core/coverage.ts +164 −0
@@ -0,0 +1,164 @@
1+import type { AssetClass, CanonicalQuote, Instrument } from "@market-atlas/market-model";
2+import { PUBLICLY_REDISTRIBUTABLE } from "@market-atlas/market-model";
3+import { instruments } from "./instruments.js";
4+import { quoteStore } from "./quotes.js";
5+
6+/**
7+ * Source Coverage Engine — "one instrument → many observers → one canonical market state".
8+ * Every quoted instrument gets a redundancy target by tier and a coverage status; the gap list is the
9+ * source-expansion queue. Counts are *independent families*, never raw connector counts.
10+ */
11+export type CoverageTier = "A" | "B" | "C" | "D";
12+export type CoverageStatus = "COVERED" | "UNDERCOVERED" | "SINGLE_SOURCE" | "NO_DATA";
13+
14+export interface CoverageTarget {
15+ tier: CoverageTier;
16+ families: number; // minimum independent families
17+ observations: number; // fresh observations (any role) aimed for
18+}
19+
20+export interface InstrumentCoverage {
21+ instrumentId: string;
22+ symbol: string;
23+ name: string;
24+ assetClass: AssetClass;
25+ tier: CoverageTier;
26+ target: CoverageTarget;
27+ families: number;
28+ observations: number;
29+ proxies: number;
30+ validators: number;
31+ liveSources: number;
32+ delayedSources: number;
33+ officialSources: number;
34+ comparability: CanonicalQuote["comparability"];
35+ score: number; // 0..1
36+ status: CoverageStatus;
37+ gap: number; // missing independent families to reach the target
38+}
39+
40+/** Tier-A majors: the instruments that must be observed through several independent paths. */
41+const TIER_A_SYMBOLS = new Set([
42+ "AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "SPY", "QQQ", "SPX", "_SPX", "NDX", "_NDX", "DJI", "_DJI", "RUT", "_RUT", "VIX", "_VIX",
43+ "BTC-USD", "ETH-USD", "BTC-USDT", "ETH-USDT", "SOL-USD", "XRP-USD",
44+ "EURUSD", "USDJPY", "GBPUSD", "USDCAD", "USDCHF", "AUDUSD",
45+ "US10Y", "US2Y", "US30Y", "US3M", "GC=F", "CL=F",
46+]);
47+const TIER_A_IDS = new Set(["crypto_btc_usd", "crypto_eth_usd", "crypto_btc_usdt", "crypto_eth_usdt", "fx_eur_usd", "fx_usd_jpy", "fx_gbp_usd", "fx_usd_cad", "index_us_spx", "index_us_ndx", "index_us_dji", "rate_us_us10y"]);
48+
49+export function tierOf(inst: Instrument): CoverageTier {
50+ // Official single-source reference series (yields, policy rates): the official publisher is the truth — one source is acceptable.
51+ if (inst.assetClass === "TREASURY" || inst.assetClass === "INTEREST_RATE" || inst.assetClass === "BOND") return "D";
52+ if (TIER_A_IDS.has(inst.id) || TIER_A_SYMBOLS.has(inst.symbol) || TIER_A_SYMBOLS.has(inst.symbol.toUpperCase())) return "A";
53+ if (inst.metadata?.featured) return "B";
54+ if (inst.assetClass === "CRYPTO" || inst.assetClass === "FOREX" || inst.assetClass === "INDEX" || inst.assetClass === "ETF") return "B";
55+ return "C";
56+}
57+
58+export const TARGETS: Record<CoverageTier, CoverageTarget> = {
59+ A: { tier: "A", families: 3, observations: 5 },
60+ B: { tier: "B", families: 2, observations: 3 },
61+ C: { tier: "C", families: 1, observations: 2 },
62+ D: { tier: "D", families: 1, observations: 1 },
63+};
64+
65+export function coverageOf(inst: Instrument, q: CanonicalQuote | undefined): InstrumentCoverage {
66+ const tier = tierOf(inst);
67+ const target = TARGETS[tier];
68+ const fresh = (q?.contributions ?? []).filter((c) => c.reason !== "stale");
69+ const included = fresh.filter((c) => c.included);
70+ const families = q?.sourceCount ?? 0;
71+ const observations = q?.observationCount ?? fresh.length;
72+ const live = included.filter((c) => c.realtimeStatus === "REALTIME").length;
73+ const delayed = included.filter((c) => c.realtimeStatus === "DELAYED").length;
74+ const official = fresh.filter((c) => c.rightsStatus === "OFFICIAL_OPEN_DATA").length;
75+ const proxies = q?.proxyCount ?? 0;
76+ const validators = q?.validatorCount ?? 0;
77+ const hasPrice = q?.price != null && PUBLICLY_REDISTRIBUTABLE.has(q.rightsStatus);
78+ const famScore = Math.min(1, families / target.families);
79+ const obsScore = Math.min(1, (observations + 0.5 * proxies + 0.5 * validators) / target.observations);
80+ const score = hasPrice ? Math.round((0.65 * famScore + 0.35 * obsScore) * 100) / 100 : 0;
81+ const status: CoverageStatus = !hasPrice ? "NO_DATA" : families >= target.families && observations + proxies + validators >= Math.min(target.observations, target.families) ? "COVERED" : families <= 1 ? "SINGLE_SOURCE" : "UNDERCOVERED";
82+ return {
83+ instrumentId: inst.id,
84+ symbol: inst.symbol,
85+ name: inst.name,
86+ assetClass: inst.assetClass,
87+ tier,
88+ target,
89+ families,
90+ observations,
91+ proxies,
92+ validators,
93+ liveSources: live,
94+ delayedSources: delayed,
95+ officialSources: official,
96+ comparability: q?.comparability ?? null,
97+ score,
98+ status,
99+ gap: Math.max(0, target.families - families),
100+ };
101+}
102+
103+export interface CoverageSummary {
104+ quoted: number;
105+ multiSource: number;
106+ byFamilies: { ">=5": number; ">=3": number; ">=2": number; "1": number; "0": number };
107+ weightedScore: number; // 0..100
108+ tiers: Record<CoverageTier, { instruments: number; covered: number; attainment: number }>;
109+ byAssetClass: Record<string, { instruments: number; multiSource: number; meanFamilies: number }>;
110+ proxiesConfirming: number;
111+ validatorsConfirming: number;
112+}
113+
114+export function coverageSummary(list: InstrumentCoverage[]): CoverageSummary {
115+ const quoted = list.filter((c) => c.status !== "NO_DATA");
116+ const count = (min: number) => quoted.filter((c) => c.families >= min).length;
117+ const tiers = {} as CoverageSummary["tiers"];
118+ for (const t of ["A", "B", "C", "D"] as CoverageTier[]) {
119+ const items = quoted.filter((c) => c.tier === t);
120+ const covered = items.filter((c) => c.status === "COVERED").length;
121+ tiers[t] = { instruments: items.length, covered, attainment: items.length ? Math.round((covered / items.length) * 1000) / 10 : 0 };
122+ }
123+ const byAssetClass: CoverageSummary["byAssetClass"] = {};
124+ for (const c of quoted) {
125+ const b = (byAssetClass[c.assetClass] ??= { instruments: 0, multiSource: 0, meanFamilies: 0 });
126+ b.instruments++;
127+ if (c.families >= 2) b.multiSource++;
128+ b.meanFamilies += c.families;
129+ }
130+ for (const b of Object.values(byAssetClass)) b.meanFamilies = b.instruments ? Math.round((b.meanFamilies / b.instruments) * 100) / 100 : 0;
131+ // Weighted redundancy: tier A counts 3×, B 2×, C/D 1×.
132+ const w = (t: CoverageTier) => (t === "A" ? 3 : t === "B" ? 2 : 1);
133+ const num = quoted.reduce((a, c) => a + w(c.tier) * c.score, 0);
134+ const den = quoted.reduce((a, c) => a + w(c.tier), 0);
135+ return {
136+ quoted: quoted.length,
137+ multiSource: count(2),
138+ byFamilies: { ">=5": count(5), ">=3": count(3), ">=2": count(2), "1": quoted.filter((c) => c.families === 1).length, "0": quoted.filter((c) => c.families === 0).length },
139+ weightedScore: den ? Math.round((num / den) * 1000) / 10 : 0,
140+ tiers,
141+ byAssetClass,
142+ proxiesConfirming: quoted.reduce((a, c) => a + c.proxies, 0),
143+ validatorsConfirming: quoted.reduce((a, c) => a + c.validators, 0),
144+ };
145+}
146+
147+/** Coverage of every instrument that has a canonical quote (cheap: in-memory). */
148+export function coverageAll(): InstrumentCoverage[] {
149+ const out: InstrumentCoverage[] = [];
150+ for (const q of quoteStore.all()) {
151+ const inst = instruments.get(q.instrumentId);
152+ if (inst && inst.isActive) out.push(coverageOf(inst, q));
153+ }
154+ return out;
155+}
156+
157+/** Source-expansion queue: the most valuable gaps first (tier weight × gap, then lowest score). */
158+export function expansionQueue(list: InstrumentCoverage[], limit = 50): InstrumentCoverage[] {
159+ const w = (t: CoverageTier) => (t === "A" ? 3 : t === "B" ? 2 : 1);
160+ return list
161+ .filter((c) => c.status !== "NO_DATA" && c.status !== "COVERED")
162+ .sort((a, b) => w(b.tier) * (b.gap + 1) - w(a.tier) * (a.gap + 1) || a.score - b.score || a.symbol.localeCompare(b.symbol))
163+ .slice(0, limit);
164+}
modified apps/api/src/core/jobs.ts +2 −0
@@ -18,6 +18,7 @@ import { pipeline } from "./pipeline.js";
18 import { quoteStore } from "./quotes.js"; 18 import { quoteStore } from "./quotes.js";
19 import { telemetry } from "./telemetry.js"; 19 import { telemetry } from "./telemetry.js";
20 import { connectorManager } from "./connector-manager.js"; 20 import { connectorManager } from "./connector-manager.js";
21+import { lineage } from "./lineage.js";
21 22
22 /** Periodic maintenance: health snapshots, market open/close events, rollups, retention/archive, backups. */ 23 /** Periodic maintenance: health snapshots, market open/close events, rollups, retention/archive, backups. */
23 export class Scheduler { 24 export class Scheduler {
@@ -30,6 +31,7 @@ export class Scheduler {
30 this.every(5_000, () => this.marketStates()); 31 this.every(5_000, () => this.marketStates());
31 this.every(10_000, () => pipeline.sweep()); 32 this.every(10_000, () => pipeline.sweep());
32 this.every(5 * 60_000, () => rollupBars(3)); 33 this.every(5 * 60_000, () => rollupBars(3));
34+ this.every(5 * 60_000, () => lineage.recompute());
33 this.every(60_000, () => this.gauges()); 35 this.every(60_000, () => this.gauges());
34 this.every(15 * 60_000, () => this.daily()); 36 this.every(15 * 60_000, () => this.daily());
35 this.marketStates(); 37 this.marketStates();
added apps/api/src/core/lineage.ts +134 −0
@@ -0,0 +1,134 @@
1+import type { Observation } from "@market-atlas/market-model";
2+import { pool } from "../db/pool.js";
3+import { logger } from "../logger.js";
4+import { telemetry } from "./telemetry.js";
5+
6+interface Sample {
7+ v: number;
8+ t: number; // source ts or receive ts
9+}
10+
11+/**
12+ * Statistical shared-upstream detection. For every instrument, recent LAST_PRICE observations of each
13+ * source are kept; two sources whose values coincide (identical price within a rounding epsilon and
14+ * within 2 s) on the vast majority of aligned samples very likely share an upstream vendor. The result is
15+ * a `similarity` in [0, 1] per source pair; pairs above the threshold are treated as one family by the
16+ * consensus engine (independence weight 0.25) even without contractual knowledge of the vendor.
17+ */
18+export class LineageEngine {
19+ private samples = new Map<string, Map<string, Sample[]>>(); // instrumentId -> sourceId -> ring
20+ private similarity = new Map<string, { similarity: number; samples: number; instruments: number }>(); // "a|b" sorted
21+ private inferredFamily = new Map<string, string>(); // sourceId -> inferred family id
22+ readonly maxPerSource = 400;
23+ readonly threshold = 0.92;
24+ readonly minSamples = 120;
25+
26+ observe(o: Observation): void {
27+ if (o.field !== "LAST_PRICE") return;
28+ let m = this.samples.get(o.instrumentId);
29+ if (!m) {
30+ m = new Map();
31+ this.samples.set(o.instrumentId, m);
32+ }
33+ let ring = m.get(o.sourceId);
34+ if (!ring) {
35+ ring = [];
36+ m.set(o.sourceId, ring);
37+ }
38+ ring.push({ v: o.value, t: o.sourceTimestamp ?? o.receivedAt });
39+ if (ring.length > this.maxPerSource) ring.splice(0, ring.length - this.maxPerSource);
40+ }
41+
42+ /** Family override for a source when statistical lineage says it mirrors another source. */
43+ familyOf(sourceId: string): string | null {
44+ return this.inferredFamily.get(sourceId) ?? null;
45+ }
46+
47+ pairs(): Array<{ sourceA: string; sourceB: string; similarity: number; samples: number; instruments: number; likelySharedUpstream: boolean }> {
48+ return [...this.similarity.entries()]
49+ .map(([k, v]) => {
50+ const [sourceA, sourceB] = k.split("|") as [string, string];
51+ return { sourceA, sourceB, ...v, likelySharedUpstream: v.similarity >= this.threshold && v.samples >= this.minSamples };
52+ })
53+ .sort((a, b) => b.similarity - a.similarity);
54+ }
55+
56+ /** Recompute similarities (every 5 min) and persist them. */
57+ async recompute(): Promise<void> {
58+ const acc = new Map<string, { matched: number; total: number; instruments: Set<string> }>();
59+ for (const [instrumentId, bySource] of this.samples) {
60+ const sources = [...bySource.keys()];
61+ for (let i = 0; i < sources.length; i++) {
62+ for (let j = i + 1; j < sources.length; j++) {
63+ const a = bySource.get(sources[i]!)!;
64+ const b = bySource.get(sources[j]!)!;
65+ if (a.length < 30 || b.length < 30) continue;
66+ const { matched, total } = align(a, b);
67+ if (!total) continue;
68+ const key = [sources[i]!, sources[j]!].sort().join("|");
69+ const e = acc.get(key) ?? { matched: 0, total: 0, instruments: new Set<string>() };
70+ e.matched += matched;
71+ e.total += total;
72+ e.instruments.add(instrumentId);
73+ acc.set(key, e);
74+ }
75+ }
76+ }
77+ this.similarity.clear();
78+ this.inferredFamily.clear();
79+ const rows: unknown[][] = [];
80+ for (const [key, e] of acc) {
81+ const sim = e.matched / e.total;
82+ this.similarity.set(key, { similarity: Math.round(sim * 1000) / 1000, samples: e.total, instruments: e.instruments.size });
83+ if (sim >= this.threshold && e.total >= this.minSamples) {
84+ const [a, b] = key.split("|") as [string, string];
85+ const fam = this.inferredFamily.get(a) ?? this.inferredFamily.get(b) ?? `inferred:${a}+${b}`;
86+ this.inferredFamily.set(a, fam);
87+ this.inferredFamily.set(b, fam);
88+ telemetry.inc("lineage_shared_upstream_pairs_total");
89+ }
90+ const [a, b] = key.split("|");
91+ rows.push([a, b, Math.round(sim * 1000) / 1000, e.total, e.instruments.size]);
92+ }
93+ if (!rows.length) return;
94+ try {
95+ const tuples = rows.map((_, i) => `($${i * 5 + 1},$${i * 5 + 2},$${i * 5 + 3},$${i * 5 + 4},$${i * 5 + 5})`);
96+ await pool.query(
97+ `insert into source_lineage (source_a, source_b, similarity, samples, instruments) values ${tuples.join(",")}
98+ on conflict (source_a, source_b) do update set similarity = excluded.similarity, samples = excluded.samples, instruments = excluded.instruments, computed_at = now()`,
99+ rows.flat(),
100+ );
101+ } catch (err) {
102+ logger.warn({ err: err instanceof Error ? err.message : String(err) }, "lineage persist failed");
103+ }
104+ }
105+}
106+
107+/** Count how many samples of `a` have a sample of `b` within 2 s with an identical value (relative 1e-6). */
108+function align(a: Sample[], b: Sample[]): { matched: number; total: number } {
109+ let j = 0;
110+ let matched = 0;
111+ let total = 0;
112+ const sb = [...b].sort((x, y) => x.t - y.t);
113+ for (const s of [...a].sort((x, y) => x.t - y.t)) {
114+ while (j < sb.length && sb[j]!.t < s.t - 2000) j++;
115+ let k = j;
116+ let hit = false;
117+ let found = false;
118+ while (k < sb.length && sb[k]!.t <= s.t + 2000) {
119+ found = true;
120+ if (Math.abs(sb[k]!.v - s.v) <= Math.abs(s.v) * 1e-6) {
121+ hit = true;
122+ break;
123+ }
124+ k++;
125+ }
126+ if (found) {
127+ total++;
128+ if (hit) matched++;
129+ }
130+ }
131+ return { matched, total };
132+}
133+
134+export const lineage = new LineageEngine();
modified apps/api/src/core/observations.ts +3 −2
@@ -63,7 +63,7 @@ export class ObservationWriter {
63 this.partitionsReady.add(d); 63 this.partitionsReady.add(d);
64 } 64 }
65 } 65 }
66− const cols = 17; 66+ const cols = 18;
67 const values: unknown[] = []; 67 const values: unknown[] = [];
68 const tuples: string[] = []; 68 const tuples: string[] = [];
69 batch.forEach((o, i) => { 69 batch.forEach((o, i) => {
@@ -87,10 +87,11 @@ export class ObservationWriter {
87 o.observationId, 87 o.observationId,
88 o.rawRef, 88 o.rawRef,
89 o.normalizerVersion, 89 o.normalizerVersion,
90+ o.observationType ?? null,
90 ); 91 );
91 }); 92 });
92 await pool.query( 93 await pool.query(
93− `insert into observations (received_at, source_ts, instrument_id, field, value, currency, source_id, connector_id, rights_status, realtime_status, timestamp_trust, confidence, latency_ms, sequence, fingerprint, raw_ref, normalizer_version) 94+ `insert into observations (received_at, source_ts, instrument_id, field, value, currency, source_id, connector_id, rights_status, realtime_status, timestamp_trust, confidence, latency_ms, sequence, fingerprint, raw_ref, normalizer_version, observation_type)
94 values ${tuples.join(",")}`, 95 values ${tuples.join(",")}`,
95 values, 96 values,
96 ); 97 );
modified apps/api/src/core/pipeline.ts +5 −1
@@ -14,6 +14,7 @@ import { instruments } from "./instruments.js";
14 import { observationWriter } from "./observations.js"; 14 import { observationWriter } from "./observations.js";
15 import { quoteStore } from "./quotes.js"; 15 import { quoteStore } from "./quotes.js";
16 import { telemetry } from "./telemetry.js"; 16 import { telemetry } from "./telemetry.js";
17+import { lineage } from "./lineage.js";
17 18
18 export interface SourceInfo { 19 export interface SourceInfo {
19 family: string | null; 20 family: string | null;
@@ -37,7 +38,7 @@ export class Pipeline {
37 (sourceId) => { 38 (sourceId) => {
38 const info = this.sourceInfo.get(sourceId); 39 const info = this.sourceInfo.get(sourceId);
39 return { 40 return {
40− family: info?.family ?? null, 41+ family: lineage.familyOf(sourceId) ?? info?.family ?? null,
41 reliability: health.sourceReliability(sourceId), 42 reliability: health.sourceReliability(sourceId),
42 realtimeStatus: info?.realtimeStatus ?? "UNKNOWN", 43 realtimeStatus: info?.realtimeStatus ?? "UNKNOWN",
43 isOfficial: info?.isOfficial ?? false, 44 isOfficial: info?.isOfficial ?? false,
@@ -49,6 +50,7 @@ export class Pipeline {
49 if (!ex || ex.sessions.continuous) return null; 50 if (!ex || ex.sessions.continuous) return null;
50 return calendar.state(ex.id) === "OPEN"; 51 return calendar.state(ex.id) === "OPEN";
51 }, 52 },
53+ (instrumentId) => instruments.get(instrumentId)?.assetClass ?? null,
52 ); 54 );
53 bus.subscribe("canonical.quote", (q) => barAggregator.onQuote(q)); 55 bus.subscribe("canonical.quote", (q) => barAggregator.onQuote(q));
54 } 56 }
@@ -86,6 +88,7 @@ export class Pipeline {
86 if (this.shouldPersist(obs)) observationWriter.enqueue(obs); 88 if (this.shouldPersist(obs)) observationWriter.enqueue(obs);
87 else telemetry.inc("observations_sampled_out_total"); 89 else telemetry.inc("observations_sampled_out_total");
88 this.consensus.ingest(obs); 90 this.consensus.ingest(obs);
91+ lineage.observe(obs);
89 bus.publish("normalized.observation", obs); 92 bus.publish("normalized.observation", obs);
90 touched.set(inst.id, inst); 93 touched.set(inst.id, inst);
91 ids.push(inst.id); 94 ids.push(inst.id);
@@ -145,6 +148,7 @@ export class Pipeline {
145 field: n.field, 148 field: n.field,
146 value: n.value, 149 value: n.value,
147 currency: n.currency ?? inst.currency ?? null, 150 currency: n.currency ?? inst.currency ?? null,
151+ observationType: n.observationType,
148 sourceTimestamp: n.sourceTimestamp, 152 sourceTimestamp: n.sourceTimestamp,
149 timestampTrust: n.timestampTrust, 153 timestampTrust: n.timestampTrust,
150 sequence: n.sequence ?? null, 154 sequence: n.sequence ?? null,
modified apps/api/src/core/quotes.ts +9 −3
@@ -50,7 +50,7 @@ export class QuoteStore {
50 this.flushing = true; 50 this.flushing = true;
51 const batch = [...this.dirty.values()].sort((a, b) => a.instrumentId.localeCompare(b.instrumentId)); 51 const batch = [...this.dirty.values()].sort((a, b) => a.instrumentId.localeCompare(b.instrumentId));
52 this.dirty.clear(); 52 this.dirty.clear();
53− const cols = 25; 53+ const cols = 29;
54 const tuples: string[] = []; 54 const tuples: string[] = [];
55 const values: unknown[] = []; 55 const values: unknown[] = [];
56 batch.forEach((q, i) => { 56 batch.forEach((q, i) => {
@@ -61,18 +61,20 @@ export class QuoteStore {
61 q.sourceCount, q.dispersionBps, q.confidence, q.freshnessMs, q.realtimeStatus, q.rightsStatus, new Date(q.updatedAt).toISOString(), 61 q.sourceCount, q.dispersionBps, q.confidence, q.freshnessMs, q.realtimeStatus, q.rightsStatus, new Date(q.updatedAt).toISOString(),
62 q.sourceTimestamp == null ? null : new Date(q.sourceTimestamp).toISOString(), q.sessionHigh, q.sessionLow, 62 q.sourceTimestamp == null ? null : new Date(q.sourceTimestamp).toISOString(), q.sessionHigh, q.sessionLow,
63 JSON.stringify(q.contributions.slice(0, 20)), q.consensusVersion, 63 JSON.stringify(q.contributions.slice(0, 20)), q.consensusVersion,
64+ q.observationCount, q.proxyCount, q.validatorCount, q.comparability,
64 ); 65 );
65 }); 66 });
66 try { 67 try {
67 await pool.query( 68 await pool.query(
68 `insert into canonical_quotes (instrument_id, symbol, price, open, high, low, previous_close, change, change_percent, volume, bid, ask, currency, 69 `insert into canonical_quotes (instrument_id, symbol, price, open, high, low, previous_close, change, change_percent, volume, bid, ask, currency,
69− source_count, dispersion_bps, confidence, freshness_ms, realtime_status, rights_status, updated_at, source_ts, session_high, session_low, contributions, consensus_version) 70+ source_count, dispersion_bps, confidence, freshness_ms, realtime_status, rights_status, updated_at, source_ts, session_high, session_low, contributions, consensus_version, observation_count, proxy_count, validator_count, comparability)
70 values ${tuples.join(",")} 71 values ${tuples.join(",")}
71 on conflict (instrument_id) do update set symbol = excluded.symbol, price = excluded.price, open = excluded.open, high = excluded.high, low = excluded.low, 72 on conflict (instrument_id) do update set symbol = excluded.symbol, price = excluded.price, open = excluded.open, high = excluded.high, low = excluded.low,
72 previous_close = excluded.previous_close, change = excluded.change, change_percent = excluded.change_percent, volume = excluded.volume, bid = excluded.bid, ask = excluded.ask, 73 previous_close = excluded.previous_close, change = excluded.change, change_percent = excluded.change_percent, volume = excluded.volume, bid = excluded.bid, ask = excluded.ask,
73 currency = excluded.currency, source_count = excluded.source_count, dispersion_bps = excluded.dispersion_bps, confidence = excluded.confidence, freshness_ms = excluded.freshness_ms, 74 currency = excluded.currency, source_count = excluded.source_count, dispersion_bps = excluded.dispersion_bps, confidence = excluded.confidence, freshness_ms = excluded.freshness_ms,
74 realtime_status = excluded.realtime_status, rights_status = excluded.rights_status, updated_at = excluded.updated_at, source_ts = excluded.source_ts, 75 realtime_status = excluded.realtime_status, rights_status = excluded.rights_status, updated_at = excluded.updated_at, source_ts = excluded.source_ts,
75− session_high = excluded.session_high, session_low = excluded.session_low, contributions = excluded.contributions, consensus_version = excluded.consensus_version`, 76+ session_high = excluded.session_high, session_low = excluded.session_low, contributions = excluded.contributions, consensus_version = excluded.consensus_version,
77+ observation_count = excluded.observation_count, proxy_count = excluded.proxy_count, validator_count = excluded.validator_count, comparability = excluded.comparability`,
76 values, 78 values,
77 ); 79 );
78 } catch (err) { 80 } catch (err) {
@@ -101,6 +103,10 @@ export function rowToQuote(row: any): CanonicalQuote {
101 ask: row.ask, 103 ask: row.ask,
102 currency: row.currency, 104 currency: row.currency,
103 sourceCount: row.source_count, 105 sourceCount: row.source_count,
106+ observationCount: row.observation_count ?? 0,
107+ proxyCount: row.proxy_count ?? 0,
108+ validatorCount: row.validator_count ?? 0,
109+ comparability: row.comparability ?? null,
104 dispersionBps: row.dispersion_bps, 110 dispersionBps: row.dispersion_bps,
105 confidence: row.confidence, 111 confidence: row.confidence,
106 freshnessMs: row.freshness_ms, 112 freshnessMs: row.freshness_ms,
modified apps/api/src/seed/countries.json +319 −53
@@ -1,54 +1,320 @@
1 [ 1 [
2− { "code": "US", "name": "United States", "region": "North America", "currency": "USD" }, 2+ {
3− { "code": "CA", "name": "Canada", "region": "North America", "currency": "CAD" }, 3+ "code": "US",
4− { "code": "MX", "name": "Mexico", "region": "North America", "currency": "MXN" }, 4+ "name": "United States",
5− { "code": "BR", "name": "Brazil", "region": "South America", "currency": "BRL" }, 5+ "region": "North America",
6− { "code": "AR", "name": "Argentina", "region": "South America", "currency": "ARS" }, 6+ "currency": "USD"
7− { "code": "CL", "name": "Chile", "region": "South America", "currency": "CLP" }, 7+ },
8− { "code": "CO", "name": "Colombia", "region": "South America", "currency": "COP" }, 8+ {
9− { "code": "GB", "name": "United Kingdom", "region": "Europe", "currency": "GBP" }, 9+ "code": "CA",
10− { "code": "IE", "name": "Ireland", "region": "Europe", "currency": "EUR" }, 10+ "name": "Canada",
11− { "code": "FR", "name": "France", "region": "Europe", "currency": "EUR" }, 11+ "region": "North America",
12− { "code": "DE", "name": "Germany", "region": "Europe", "currency": "EUR" }, 12+ "currency": "CAD"
13− { "code": "NL", "name": "Netherlands", "region": "Europe", "currency": "EUR" }, 13+ },
14− { "code": "BE", "name": "Belgium", "region": "Europe", "currency": "EUR" }, 14+ {
15− { "code": "PT", "name": "Portugal", "region": "Europe", "currency": "EUR" }, 15+ "code": "MX",
16− { "code": "ES", "name": "Spain", "region": "Europe", "currency": "EUR" }, 16+ "name": "Mexico",
17− { "code": "IT", "name": "Italy", "region": "Europe", "currency": "EUR" }, 17+ "region": "North America",
18− { "code": "CH", "name": "Switzerland", "region": "Europe", "currency": "CHF" }, 18+ "currency": "MXN"
19− { "code": "AT", "name": "Austria", "region": "Europe", "currency": "EUR" }, 19+ },
20− { "code": "SE", "name": "Sweden", "region": "Europe", "currency": "SEK" }, 20+ {
21− { "code": "NO", "name": "Norway", "region": "Europe", "currency": "NOK" }, 21+ "code": "BR",
22− { "code": "DK", "name": "Denmark", "region": "Europe", "currency": "DKK" }, 22+ "name": "Brazil",
23− { "code": "FI", "name": "Finland", "region": "Europe", "currency": "EUR" }, 23+ "region": "South America",
24− { "code": "PL", "name": "Poland", "region": "Europe", "currency": "PLN" }, 24+ "currency": "BRL"
25− { "code": "CZ", "name": "Czechia", "region": "Europe", "currency": "CZK" }, 25+ },
26− { "code": "HU", "name": "Hungary", "region": "Europe", "currency": "HUF" }, 26+ {
27− { "code": "GR", "name": "Greece", "region": "Europe", "currency": "EUR" }, 27+ "code": "AR",
28− { "code": "TR", "name": "Türkiye", "region": "Europe", "currency": "TRY" }, 28+ "name": "Argentina",
29− { "code": "RU", "name": "Russia", "region": "Europe", "currency": "RUB" }, 29+ "region": "South America",
30− { "code": "IL", "name": "Israel", "region": "Middle East", "currency": "ILS" }, 30+ "currency": "ARS"
31− { "code": "SA", "name": "Saudi Arabia", "region": "Middle East", "currency": "SAR" }, 31+ },
32− { "code": "AE", "name": "United Arab Emirates", "region": "Middle East", "currency": "AED" }, 32+ {
33− { "code": "QA", "name": "Qatar", "region": "Middle East", "currency": "QAR" }, 33+ "code": "CL",
34− { "code": "EG", "name": "Egypt", "region": "Africa", "currency": "EGP" }, 34+ "name": "Chile",
35− { "code": "ZA", "name": "South Africa", "region": "Africa", "currency": "ZAR" }, 35+ "region": "South America",
36− { "code": "NG", "name": "Nigeria", "region": "Africa", "currency": "NGN" }, 36+ "currency": "CLP"
37− { "code": "KE", "name": "Kenya", "region": "Africa", "currency": "KES" }, 37+ },
38− { "code": "JP", "name": "Japan", "region": "Asia-Pacific", "currency": "JPY" }, 38+ {
39− { "code": "CN", "name": "China", "region": "Asia-Pacific", "currency": "CNY" }, 39+ "code": "CO",
40− { "code": "HK", "name": "Hong Kong", "region": "Asia-Pacific", "currency": "HKD" }, 40+ "name": "Colombia",
41− { "code": "TW", "name": "Taiwan", "region": "Asia-Pacific", "currency": "TWD" }, 41+ "region": "South America",
42− { "code": "KR", "name": "South Korea", "region": "Asia-Pacific", "currency": "KRW" }, 42+ "currency": "COP"
43− { "code": "SG", "name": "Singapore", "region": "Asia-Pacific", "currency": "SGD" }, 43+ },
44− { "code": "MY", "name": "Malaysia", "region": "Asia-Pacific", "currency": "MYR" }, 44+ {
45− { "code": "TH", "name": "Thailand", "region": "Asia-Pacific", "currency": "THB" }, 45+ "code": "GB",
46− { "code": "ID", "name": "Indonesia", "region": "Asia-Pacific", "currency": "IDR" }, 46+ "name": "United Kingdom",
47− { "code": "PH", "name": "Philippines", "region": "Asia-Pacific", "currency": "PHP" }, 47+ "region": "Europe",
48− { "code": "VN", "name": "Vietnam", "region": "Asia-Pacific", "currency": "VND" }, 48+ "currency": "GBP"
49− { "code": "IN", "name": "India", "region": "Asia-Pacific", "currency": "INR" }, 49+ },
50− { "code": "AU", "name": "Australia", "region": "Asia-Pacific", "currency": "AUD" }, 50+ {
51− { "code": "NZ", "name": "New Zealand", "region": "Asia-Pacific", "currency": "NZD" }, 51+ "code": "IE",
52− { "code": "EU", "name": "Euro area", "region": "Europe", "currency": "EUR" }, 52+ "name": "Ireland",
53− { "code": "XX", "name": "Global / cross-border", "region": "Global", "currency": null } 53+ "region": "Europe",
54−] 54+ "currency": "EUR"
55+ },
56+ {
57+ "code": "FR",
58+ "name": "France",
59+ "region": "Europe",
60+ "currency": "EUR"
61+ },
62+ {
63+ "code": "DE",
64+ "name": "Germany",
65+ "region": "Europe",
66+ "currency": "EUR"
67+ },
68+ {
69+ "code": "NL",
70+ "name": "Netherlands",
71+ "region": "Europe",
72+ "currency": "EUR"
73+ },
74+ {
75+ "code": "BE",
76+ "name": "Belgium",
77+ "region": "Europe",
78+ "currency": "EUR"
79+ },
80+ {
81+ "code": "PT",
82+ "name": "Portugal",
83+ "region": "Europe",
84+ "currency": "EUR"
85+ },
86+ {
87+ "code": "ES",
88+ "name": "Spain",
89+ "region": "Europe",
90+ "currency": "EUR"
91+ },
92+ {
93+ "code": "IT",
94+ "name": "Italy",
95+ "region": "Europe",
96+ "currency": "EUR"
97+ },
98+ {
99+ "code": "CH",
100+ "name": "Switzerland",
101+ "region": "Europe",
102+ "currency": "CHF"
103+ },
104+ {
105+ "code": "AT",
106+ "name": "Austria",
107+ "region": "Europe",
108+ "currency": "EUR"
109+ },
110+ {
111+ "code": "SE",
112+ "name": "Sweden",
113+ "region": "Europe",
114+ "currency": "SEK"
115+ },
116+ {
117+ "code": "NO",
118+ "name": "Norway",
119+ "region": "Europe",
120+ "currency": "NOK"
121+ },
122+ {
123+ "code": "DK",
124+ "name": "Denmark",
125+ "region": "Europe",
126+ "currency": "DKK"
127+ },
128+ {
129+ "code": "FI",
130+ "name": "Finland",
131+ "region": "Europe",
132+ "currency": "EUR"
133+ },
134+ {
135+ "code": "PL",
136+ "name": "Poland",
137+ "region": "Europe",
138+ "currency": "PLN"
139+ },
140+ {
141+ "code": "CZ",
142+ "name": "Czechia",
143+ "region": "Europe",
144+ "currency": "CZK"
145+ },
146+ {
147+ "code": "HU",
148+ "name": "Hungary",
149+ "region": "Europe",
150+ "currency": "HUF"
151+ },
152+ {
153+ "code": "GR",
154+ "name": "Greece",
155+ "region": "Europe",
156+ "currency": "EUR"
157+ },
158+ {
159+ "code": "TR",
160+ "name": "Türkiye",
161+ "region": "Europe",
162+ "currency": "TRY"
163+ },
164+ {
165+ "code": "RU",
166+ "name": "Russia",
167+ "region": "Europe",
168+ "currency": "RUB"
169+ },
170+ {
171+ "code": "IL",
172+ "name": "Israel",
173+ "region": "Middle East",
174+ "currency": "ILS"
175+ },
176+ {
177+ "code": "SA",
178+ "name": "Saudi Arabia",
179+ "region": "Middle East",
180+ "currency": "SAR"
181+ },
182+ {
183+ "code": "AE",
184+ "name": "United Arab Emirates",
185+ "region": "Middle East",
186+ "currency": "AED"
187+ },
188+ {
189+ "code": "QA",
190+ "name": "Qatar",
191+ "region": "Middle East",
192+ "currency": "QAR"
193+ },
194+ {
195+ "code": "EG",
196+ "name": "Egypt",
197+ "region": "Africa",
198+ "currency": "EGP"
199+ },
200+ {
201+ "code": "ZA",
202+ "name": "South Africa",
203+ "region": "Africa",
204+ "currency": "ZAR"
205+ },
206+ {
207+ "code": "NG",
208+ "name": "Nigeria",
209+ "region": "Africa",
210+ "currency": "NGN"
211+ },
212+ {
213+ "code": "KE",
214+ "name": "Kenya",
215+ "region": "Africa",
216+ "currency": "KES"
217+ },
218+ {
219+ "code": "JP",
220+ "name": "Japan",
221+ "region": "Asia-Pacific",
222+ "currency": "JPY"
223+ },
224+ {
225+ "code": "CN",
226+ "name": "China",
227+ "region": "Asia-Pacific",
228+ "currency": "CNY"
229+ },
230+ {
231+ "code": "HK",
232+ "name": "Hong Kong",
233+ "region": "Asia-Pacific",
234+ "currency": "HKD"
235+ },
236+ {
237+ "code": "TW",
238+ "name": "Taiwan",
239+ "region": "Asia-Pacific",
240+ "currency": "TWD"
241+ },
242+ {
243+ "code": "KR",
244+ "name": "South Korea",
245+ "region": "Asia-Pacific",
246+ "currency": "KRW"
247+ },
248+ {
249+ "code": "SG",
250+ "name": "Singapore",
251+ "region": "Asia-Pacific",
252+ "currency": "SGD"
253+ },
254+ {
255+ "code": "MY",
256+ "name": "Malaysia",
257+ "region": "Asia-Pacific",
258+ "currency": "MYR"
259+ },
260+ {
261+ "code": "TH",
262+ "name": "Thailand",
263+ "region": "Asia-Pacific",
264+ "currency": "THB"
265+ },
266+ {
267+ "code": "ID",
268+ "name": "Indonesia",
269+ "region": "Asia-Pacific",
270+ "currency": "IDR"
271+ },
272+ {
273+ "code": "PH",
274+ "name": "Philippines",
275+ "region": "Asia-Pacific",
276+ "currency": "PHP"
277+ },
278+ {
279+ "code": "VN",
280+ "name": "Vietnam",
281+ "region": "Asia-Pacific",
282+ "currency": "VND"
283+ },
284+ {
285+ "code": "IN",
286+ "name": "India",
287+ "region": "Asia-Pacific",
288+ "currency": "INR"
289+ },
290+ {
291+ "code": "AU",
292+ "name": "Australia",
293+ "region": "Asia-Pacific",
294+ "currency": "AUD"
295+ },
296+ {
297+ "code": "NZ",
298+ "name": "New Zealand",
299+ "region": "Asia-Pacific",
300+ "currency": "NZD"
301+ },
302+ {
303+ "code": "EU",
304+ "name": "Euro area",
305+ "region": "Europe",
306+ "currency": "EUR"
307+ },
308+ {
309+ "code": "XX",
310+ "name": "Global / cross-border",
311+ "region": "Global",
312+ "currency": null
313+ },
314+ {
315+ "code": "SC",
316+ "name": "Seychelles",
317+ "region": "Africa",
318+ "currency": "SCR"
319+ }
320+]
\ No newline at end of file
modified apps/api/src/seed/exchanges.json +1731 −62
Large diff (1,795 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/_shared/crypto.ts +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/_shared/pairs.ts +102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/bank-of-canada/index.ts +3 −3
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/binance-ws/index.ts +9 −5
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/bitfinex-ws/README.md +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/bitfinex-ws/fixtures/ticker.json +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/bitfinex-ws/index.ts +94 −0
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/bitstamp-ws/README.md +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/bitstamp-ws/fixtures/book.json +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/bitstamp-ws/fixtures/trade.json +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/bitstamp-ws/index.ts +96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/bybit-ws/README.md +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/bybit-ws/fixtures/ticker.json +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/bybit-ws/index.ts +79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/cboe-delayed-quotes/index.ts +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/coinbase-ws/index.ts +9 −5
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/connectors.test.ts +82 −0
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/cryptocom-ws/README.md +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/cryptocom-ws/fixtures/ticker.json +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/cryptocom-ws/index.ts +77 −0
Large diff (77 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/ecb-frankfurter/index.ts +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/gate-ws/README.md +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/gate-ws/fixtures/ticker.json +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/gate-ws/index.ts +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/gemini-ws/README.md +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/gemini-ws/fixtures/trade.json +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/gemini-ws/index.ts +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/hfmarketdata/index.ts +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/index.ts +16 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/kraken-ws/index.ts +12 −6
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/kucoin-ws/README.md +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/kucoin-ws/fixtures/ticker.json +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/kucoin-ws/index.ts +85 −0
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/nasdaq-quote-api/README.md +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/nasdaq-quote-api/fixtures/info.json +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added connectors/src/nasdaq-quote-api/index.ts +95 −0
Large diff (95 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/okx-ws/index.ts +9 −5
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/sources.ts +8 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified connectors/src/us-treasury-yield-curve/index.ts +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified docs/API.md +5 −1
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
modified docs/ARCHITECTURE.md +18 −3
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
modified docs/CONNECTORS.md +7 −3
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added infra/migrations/0002_observation_types.sql +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
modified packages/market-model/src/enums.ts +38 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
modified packages/market-model/src/schemas.ts +2 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified packages/market-model/src/types.ts +16 −2
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme