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>
60 changed files +3,739 −247
modified
apps/api/src/api/public-view.ts
+4 −0
@@ -48,6 +48,10 @@ export function publicQuote(q: CanonicalQuote | undefined, inst?: Instrument, ma | ||
| 48 | 48 | ask: v(q.ask), |
| 49 | 49 | currency: q.currency, |
| 50 | 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 | 55 | dispersion_bps: q.dispersionBps, |
| 52 | 56 | confidence: q.confidence, |
| 53 | 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 | 10 | import { assertPublicUrl, redactObject } from "@market-atlas/connector-sdk"; |
| 11 | 11 | import { ApiError, envelope, notFound } from "../server.js"; |
| 12 | 12 | import { runDiscovery } from "../../discovery/probe.js"; |
| 13 | +import { lineage } from "../../core/lineage.js"; | |
| 13 | 14 | |
| 14 | 15 | function requireAdmin(req: FastifyRequest) { |
| 15 | 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 | 134 | app.get("/v1/admin/divergence", async () => { |
| 132 | 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 | 136 | return envelope(r.rows); |
modified
apps/api/src/api/routes/markets.ts
+23 −1
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | import type { FastifyInstance } from "fastify"; |
| 2 | 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 | 4 | import { pool } from "../../db/pool.js"; |
| 5 | 5 | import { calendar } from "../../core/calendar.js"; |
| 6 | 6 | import { instruments } from "../../core/instruments.js"; |
@@ -8,6 +8,9 @@ import { quoteStore } from "../../core/quotes.js"; | ||
| 8 | 8 | import { badRequest, envelope, notFound } from "../server.js"; |
| 9 | 9 | import { publicInstrument, publicQuote } from "../public-view.js"; |
| 10 | 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 | 15 | const quoted = (pred: (i: Instrument) => boolean) => |
| 13 | 16 | instruments |
@@ -26,6 +29,25 @@ function featured(cls: AssetClass, n: number) { | ||
| 26 | 29 | } |
| 27 | 30 | |
| 28 | 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 | 51 | /** Global market overview for the homepage and /markets. */ |
| 30 | 52 | app.get("/v1/markets", async () => { |
| 31 | 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 | 13 | import { badRequest, envelope, notFound } from "../server.js"; |
| 14 | 14 | import { publicInstrument, publicQuote } from "../public-view.js"; |
| 15 | 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 | 19 | const q = <T extends z.ZodTypeAny>(schema: T, input: unknown): z.infer<T> => { |
| 18 | 20 | const r = schema.safeParse(input); |
@@ -111,6 +113,7 @@ export async function registerPublicRoutes(app: FastifyInstance) { | ||
| 111 | 113 | events_per_min: round(telemetry.rate("events_total") * 60, 2), |
| 112 | 114 | filings_24h: filings.rows[0]?.n ?? 0, |
| 113 | 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 | 117 | stream_clients: telemetry.counter("stream_clients_connected") - telemetry.counter("stream_clients_disconnected"), |
| 115 | 118 | uptime_s: Math.round(process.uptime()), |
| 116 | 119 | }); |
@@ -205,12 +208,18 @@ export async function registerPublicRoutes(app: FastifyInstance) { | ||
| 205 | 208 | confidence: quote.confidence, |
| 206 | 209 | dispersion_bps: quote.dispersionBps, |
| 207 | 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 | 217 | freshness_ms: quote.freshnessMs, |
| 209 | 218 | realtime_status: quote.realtimeStatus, |
| 210 | 219 | rights_status: quote.rightsStatus, |
| 211 | 220 | consensus_version: quote.consensusVersion, |
| 212 | 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 | 223 | contributions: quote.contributions.map((c) => ({ |
| 215 | 224 | source_id: c.sourceId, |
| 216 | 225 | source: sourceMeta[c.sourceId] ?? null, |
@@ -222,6 +231,8 @@ export async function registerPublicRoutes(app: FastifyInstance) { | ||
| 222 | 231 | weight: c.weight, |
| 223 | 232 | included: c.included, |
| 224 | 233 | reason: c.reason ?? null, |
| 234 | + observation_type: c.observationType, | |
| 235 | + delta_bps: c.deltaBps ?? null, | |
| 225 | 236 | realtime_status: c.realtimeStatus, |
| 226 | 237 | rights_status: c.rightsStatus, |
| 227 | 238 | reliability_score: health.sourceReliability(c.sourceId), |
@@ -361,6 +372,8 @@ export async function registerPublicRoutes(app: FastifyInstance) { | ||
| 361 | 372 | last_observation: last ? new Date(last).toISOString() : null, |
| 362 | 373 | reliability_score: snaps.map((x) => x.reliabilityScore).filter((x): x is number => x != null).sort((a, b) => b - a)[0] ?? null, |
| 363 | 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 | 377 | description: conns[0]?.def.metadata.description ?? null, |
| 365 | 378 | rights_notes: conns[0]?.def.metadata.rightsNotes ?? null, |
| 366 | 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 | 103 | const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!; |
| 104 | 104 | expect(q.price).toBe(100); |
| 105 | 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 | 108 | it("market closed / everything stale → STALE status with last known value and zero confidence", () => { |
| 109 | 109 | const e = engine(); |
@@ -130,3 +130,58 @@ describe("consensus engine", () => { | ||
| 130 | 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"; | |
| 2 | −import { CANONICAL_VERSIONS, PUBLICLY_REDISTRIBUTABLE } 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, COMPARABILITY, PROXY_TYPES, PUBLICLY_REDISTRIBUTABLE } from "@market-atlas/market-model"; | |
| 3 | 3 | import { config } from "../config.js"; |
| 4 | 4 | |
| 5 | 5 | export interface SourceProfile { |
@@ -13,7 +13,7 @@ interface Latest { | ||
| 13 | 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 | 17 | const FRESH_WINDOW_MS: Record<RealtimeStatus, number> = { |
| 18 | 18 | REALTIME: config.consensusFreshWindowMs, |
| 19 | 19 | DELAYED: 30 * 60_000, |
@@ -24,23 +24,54 @@ const FRESH_WINDOW_MS: Record<RealtimeStatus, number> = { | ||
| 24 | 24 | }; |
| 25 | 25 | |
| 26 | 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 | |
| 30 | − * weighted median price over fresh observations, discounts sources sharing an upstream family, | |
| 31 | − * flags outliers, and exposes the full contribution list ("Why this price?"). | |
| 56 | + * Per-instrument consensus. Keeps the latest observation of each (source, field), keeps only | |
| 57 | + * *comparable* observations (same class — live / official fix / end-of-day — and close in time), | |
| 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 | 62 | export class ConsensusEngine { |
| 34 | 63 | private latest = new Map<string, Map<string, Latest>>(); // instrumentId -> `${sourceId}|${field}` -> latest |
| 35 | 64 | private session = new Map<string, { high: number; low: number; startedAt: number }>(); |
| 36 | 65 | |
| 37 | 66 | /** |
| 38 | − * @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 | |
| 67 | + * @param profiles source reliability/family lookup | |
| 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 | 71 | constructor( |
| 42 | 72 | private profiles: (sourceId: string) => SourceProfile, |
| 43 | 73 | private marketOpen: (instrumentId: string) => boolean | null = () => null, |
| 74 | + private assetClassOf: (instrumentId: string) => AssetClass | null = () => null, | |
| 44 | 75 | ) {} |
| 45 | 76 | |
| 46 | 77 | ingest(o: Observation): void { |
@@ -78,12 +109,20 @@ export class ConsensusEngine { | ||
| 78 | 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 | 119 | compute(instrumentId: string, symbol: string, now = Date.now()): CanonicalQuote | null { |
| 82 | 120 | const m = this.latest.get(instrumentId); |
| 83 | 121 | if (!m) return null; |
| 84 | 122 | const closed = this.marketOpen(instrumentId) === false; |
| 85 | − const price = this.aggregate(m, "LAST_PRICE", now, closed); | |
| 86 | − const pick = (field: string) => this.aggregate(m, field, now, closed); | |
| 123 | + const assetClass = this.assetClassOf(instrumentId); | |
| 124 | + const price = this.aggregate(m, "LAST_PRICE", now, closed, assetClass); | |
| 125 | + const pick = (field: string) => this.aggregate(m, field, now, closed, assetClass); | |
| 87 | 126 | const open = pick("OPEN"); |
| 88 | 127 | const high = pick("HIGH"); |
| 89 | 128 | const low = pick("LOW"); |
@@ -104,7 +143,6 @@ export class ConsensusEngine { | ||
| 104 | 143 | change = p - open.value; |
| 105 | 144 | changePercent = (change / open.value) * 100; |
| 106 | 145 | } |
| 107 | − // Session high/low derived by Market Atlas itself. | |
| 108 | 146 | let sess = this.session.get(instrumentId); |
| 109 | 147 | if (p != null) { |
| 110 | 148 | if (!sess) { |
@@ -135,6 +173,10 @@ export class ConsensusEngine { | ||
| 135 | 173 | ask: ask.value, |
| 136 | 174 | currency, |
| 137 | 175 | sourceCount: price.independentFamilies, |
| 176 | + observationCount: price.observationCount, | |
| 177 | + proxyCount: price.proxyCount, | |
| 178 | + validatorCount: price.validatorCount, | |
| 179 | + comparability: price.comparability, | |
| 138 | 180 | dispersionBps: price.dispersionBps, |
| 139 | 181 | confidence: price.confidence, |
| 140 | 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 | 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 | 212 | for (const [key, l] of m) { |
| 156 | 213 | if (!key.endsWith(`|${field}`)) continue; |
| 157 | 214 | const o = l.obs; |
| 158 | 215 | const prof = this.profiles(o.sourceId); |
| 216 | + const type = inferObservationType(o); | |
| 159 | 217 | const eventTs = o.sourceTimestamp ?? o.receivedAt; |
| 160 | 218 | const ageMs = Math.max(0, now - eventTs); |
| 161 | 219 | // While the venue is closed, the last session value is the truth for up to 4 days (long weekends). |
| 162 | 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 | 222 | const base = 0.35 + 0.65 * prof.reliability; |
| 165 | 223 | const tsQuality = o.timestampTrust === "EXCHANGE" ? 1 : o.timestampTrust === "SOURCE" ? 0.9 : 0.75; |
| 166 | 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; | |
| 168 | − const decay = fresh ? Math.exp(-ageMs / Math.max(1000, window)) : 0; | |
| 225 | + const official = prof.isOfficial ? (assetClass === "TREASURY" || assetClass === "INTEREST_RATE" || assetClass === "BOND" ? 1.6 : 1.15) : 1; | |
| 226 | + const decay = isFresh ? Math.exp(-ageMs / Math.max(1000, window)) : 0; | |
| 169 | 227 | const weight = base * tsQuality * rtQuality * official * (0.4 + 0.6 * decay) * (o.confidence ?? 1); |
| 170 | − const c: SourceContribution = { | |
| 228 | + const contrib: SourceContribution = { | |
| 171 | 229 | sourceId: o.sourceId, |
| 172 | 230 | connectorId: o.connectorId, |
| 173 | 231 | value: o.value, |
| 174 | 232 | sourceTimestamp: o.sourceTimestamp, |
| 175 | 233 | receivedAt: o.receivedAt, |
| 176 | 234 | ageMs, |
| 177 | − weight: fresh ? round(weight, 4) : 0, | |
| 178 | − included: fresh, | |
| 179 | − realtimeStatus: fresh ? o.realtimeStatus : "STALE", | |
| 235 | + weight: 0, | |
| 236 | + included: false, | |
| 237 | + observationType: type, | |
| 238 | + deltaBps: null, | |
| 239 | + realtimeStatus: isFresh ? o.realtimeStatus : "STALE", | |
| 180 | 240 | rightsStatus: o.rightsStatus, |
| 181 | − ...(fresh ? {} : { reason: "stale" }), | |
| 241 | + ...(isFresh ? {} : { reason: "stale" }), | |
| 182 | 242 | }; |
| 183 | − contributions.push(c); | |
| 184 | − if (fresh) candidates.push({ value: o.value, weight, family: prof.family ?? o.sourceId, obs: o }); | |
| 243 | + contributions.push(contrib); | |
| 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 | 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]; | |
| 189 | − return { | |
| 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 | − }; | |
| 248 | + const newest = [...contributions].sort((a, b) => a.ageMs - b.ageMs)[0]; | |
| 249 | + return { ...empty, value: newest?.value ?? null, freshnessMs: newest?.ageMs ?? null, sourceTimestamp: newest?.sourceTimestamp ?? null, contributions: contributions.sort((a, b) => a.ageMs - b.ageMs) }; | |
| 199 | 250 | } |
| 200 | − // Tiering: when live (REALTIME/DELAYED) observations exist, end-of-day/indicative values are superseded. | |
| 201 | − const bestRank = Math.max(...candidates.map((c) => REALTIME_RANK[c.obs.realtimeStatus])); | |
| 202 | − if (bestRank >= 3) { | |
| 203 | − for (const c of candidates) { | |
| 204 | − if (REALTIME_RANK[c.obs.realtimeStatus] <= 2) { | |
| 205 | − const contrib = contributions.find((x) => x.sourceId === c.obs.sourceId && x.included); | |
| 206 | − if (contrib) { | |
| 207 | − contrib.included = false; | |
| 208 | − contrib.reason = "superseded_by_live"; | |
| 209 | − contrib.weight = 0; | |
| 210 | − } | |
| 211 | − } | |
| 251 | + | |
| 252 | + // 1. Comparability class: live values from real markets win, then official fixings, then end-of-day closes, | |
| 253 | + // then live proxies alone (stablecoin markets over a weekend). Other classes are reported, never mixed. | |
| 254 | + const cls = (c: Candidate) => COMPARABILITY[c.type]; | |
| 255 | + const hasRealLive = fresh.some((c) => cls(c) === "LIVE" && !c.proxy); | |
| 256 | + const chosen: ComparabilityClass = hasRealLive ? "LIVE" : fresh.some((c) => cls(c) === "FIX") ? "FIX" : fresh.some((c) => cls(c) === "EOD") ? "EOD" : "LIVE"; | |
| 257 | + let pool = fresh.filter((c) => cls(c) === chosen); | |
| 258 | + for (const c of fresh) if (cls(c) !== chosen) exclude(c, "not_comparable"); | |
| 259 | + | |
| 260 | + // 2. Temporal tolerance inside the class. | |
| 261 | + const tsOf = (c: Candidate) => c.obs.sourceTimestamp ?? c.obs.receivedAt; | |
| 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); | |
| 214 | − candidates.length = 0; | |
| 215 | − candidates.push(...live); | |
| 290 | + const max = Math.max(0, ...vols.values()); | |
| 291 | + if (max > 0) for (const c of voters) c.weight *= 0.6 + 0.8 * ((vols.get(c.obs.sourceId) ?? 0) / max); | |
| 216 | 292 | } |
| 217 | − // Family de-duplication: one vote per upstream family (the freshest), others get a small weight. | |
| 218 | − const seenFamily = new Map<string, number>(); | |
| 219 | − for (const c of candidates) { | |
| 220 | − const prior = seenFamily.get(c.family); | |
| 221 | − if (prior == null) seenFamily.set(c.family, c.weight); | |
| 222 | − else c.weight *= 0.25; | |
| 293 | + | |
| 294 | + // 6. Outlier rejection (> 2 % from the weighted median of real voters, when at least 3 voters). | |
| 295 | + const reference = realVoters.length ? realVoters : voters; | |
| 296 | + let median = weightedMedian(reference.map((c) => ({ v: c.value, w: c.weight }))); | |
| 297 | + if (voters.length >= 3 && median !== 0) { | |
| 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 }))); | |
| 225 | − // Outlier rejection: > 2% away from median with ≥ 3 candidates → excluded. | |
| 226 | − let usable = candidates; | |
| 227 | − if (candidates.length >= 3 && median !== 0) { | |
| 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; | |
| 303 | + const finalVoters = voters; | |
| 304 | + for (const c of finalVoters) { | |
| 305 | + c.contrib.included = true; | |
| 306 | + c.contrib.weight = round(c.weight, 4); | |
| 240 | 307 | } |
| 241 | − const finalMedian = weightedMedian(usable.map((c) => ({ v: c.value, w: c.weight }))); | |
| 242 | − const values = usable.map((c) => c.value); | |
| 243 | − const spread = values.length > 1 ? (Math.max(...values) - Math.min(...values)) / Math.abs(finalMedian || 1) : 0; | |
| 244 | − const dispersionBps = round(spread * 10_000, 2); | |
| 245 | − const families = new Set(usable.map((c) => c.family)).size; | |
| 246 | − const newest = Math.min(...usable.map((c) => Math.max(0, now - (c.obs.sourceTimestamp ?? c.obs.receivedAt)))); | |
| 247 | − const bestRt = usable.map((c) => c.obs.realtimeStatus).sort((a, b) => REALTIME_RANK[b] - REALTIME_RANK[a])[0] ?? "UNKNOWN"; | |
| 248 | − // Confidence: agreement (dispersion), redundancy (families), freshness. Never claims > 0.995. | |
| 308 | + // Deltas vs canonical for every fresh contribution (included or not). | |
| 309 | + for (const c of fresh) c.contrib.deltaBps = median ? round(((c.value - median) / Math.abs(median)) * 10_000, 2) : null; | |
| 310 | + | |
| 311 | + const realIncluded = finalVoters.filter((c) => !c.proxy); | |
| 312 | + const spreadBasis = realIncluded.length ? realIncluded : finalVoters; | |
| 313 | + const values = spreadBasis.map((c) => c.value); | |
| 314 | + const spread = values.length > 1 ? (Math.max(...values) - Math.min(...values)) / Math.abs(median || 1) : 0; | |
| 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 | 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 | 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 | 327 | const confidence = round(Math.min(0.995, 0.15 + 0.35 * agreement + 0.25 * redundancy + 0.1 * freshness + 0.15 * avgReliability), 3); |
| 254 | 328 | return { |
| 255 | − value: finalMedian, | |
| 329 | + value: median, | |
| 256 | 330 | confidence, |
| 257 | − dispersionBps, | |
| 331 | + dispersionBps: round(spread * 10_000, 2), | |
| 258 | 332 | independentFamilies: families, |
| 333 | + observationCount: fresh.length, | |
| 334 | + proxyCount, | |
| 335 | + validatorCount, | |
| 336 | + comparability: chosen, | |
| 259 | 337 | freshnessMs: Math.round(newest), |
| 260 | 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 | 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 | 355 | export function weightedMedian(items: Array<{ v: number; w: number }>): number { |
| 268 | 356 | if (!items.length) return NaN; |
| 269 | 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 | 18 | import { quoteStore } from "./quotes.js"; |
| 19 | 19 | import { telemetry } from "./telemetry.js"; |
| 20 | 20 | import { connectorManager } from "./connector-manager.js"; |
| 21 | +import { lineage } from "./lineage.js"; | |
| 21 | 22 | |
| 22 | 23 | /** Periodic maintenance: health snapshots, market open/close events, rollups, retention/archive, backups. */ |
| 23 | 24 | export class Scheduler { |
@@ -30,6 +31,7 @@ export class Scheduler { | ||
| 30 | 31 | this.every(5_000, () => this.marketStates()); |
| 31 | 32 | this.every(10_000, () => pipeline.sweep()); |
| 32 | 33 | this.every(5 * 60_000, () => rollupBars(3)); |
| 34 | + this.every(5 * 60_000, () => lineage.recompute()); | |
| 33 | 35 | this.every(60_000, () => this.gauges()); |
| 34 | 36 | this.every(15 * 60_000, () => this.daily()); |
| 35 | 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 | 63 | this.partitionsReady.add(d); |
| 64 | 64 | } |
| 65 | 65 | } |
| 66 | − const cols = 17; | |
| 66 | + const cols = 18; | |
| 67 | 67 | const values: unknown[] = []; |
| 68 | 68 | const tuples: string[] = []; |
| 69 | 69 | batch.forEach((o, i) => { |
@@ -87,10 +87,11 @@ export class ObservationWriter { | ||
| 87 | 87 | o.observationId, |
| 88 | 88 | o.rawRef, |
| 89 | 89 | o.normalizerVersion, |
| 90 | + o.observationType ?? null, | |
| 90 | 91 | ); |
| 91 | 92 | }); |
| 92 | 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 | 95 | values ${tuples.join(",")}`, |
| 95 | 96 | values, |
| 96 | 97 | ); |
modified
apps/api/src/core/pipeline.ts
+5 −1
@@ -14,6 +14,7 @@ import { instruments } from "./instruments.js"; | ||
| 14 | 14 | import { observationWriter } from "./observations.js"; |
| 15 | 15 | import { quoteStore } from "./quotes.js"; |
| 16 | 16 | import { telemetry } from "./telemetry.js"; |
| 17 | +import { lineage } from "./lineage.js"; | |
| 17 | 18 | |
| 18 | 19 | export interface SourceInfo { |
| 19 | 20 | family: string | null; |
@@ -37,7 +38,7 @@ export class Pipeline { | ||
| 37 | 38 | (sourceId) => { |
| 38 | 39 | const info = this.sourceInfo.get(sourceId); |
| 39 | 40 | return { |
| 40 | − family: info?.family ?? null, | |
| 41 | + family: lineage.familyOf(sourceId) ?? info?.family ?? null, | |
| 41 | 42 | reliability: health.sourceReliability(sourceId), |
| 42 | 43 | realtimeStatus: info?.realtimeStatus ?? "UNKNOWN", |
| 43 | 44 | isOfficial: info?.isOfficial ?? false, |
@@ -49,6 +50,7 @@ export class Pipeline { | ||
| 49 | 50 | if (!ex || ex.sessions.continuous) return null; |
| 50 | 51 | return calendar.state(ex.id) === "OPEN"; |
| 51 | 52 | }, |
| 53 | + (instrumentId) => instruments.get(instrumentId)?.assetClass ?? null, | |
| 52 | 54 | ); |
| 53 | 55 | bus.subscribe("canonical.quote", (q) => barAggregator.onQuote(q)); |
| 54 | 56 | } |
@@ -86,6 +88,7 @@ export class Pipeline { | ||
| 86 | 88 | if (this.shouldPersist(obs)) observationWriter.enqueue(obs); |
| 87 | 89 | else telemetry.inc("observations_sampled_out_total"); |
| 88 | 90 | this.consensus.ingest(obs); |
| 91 | + lineage.observe(obs); | |
| 89 | 92 | bus.publish("normalized.observation", obs); |
| 90 | 93 | touched.set(inst.id, inst); |
| 91 | 94 | ids.push(inst.id); |
@@ -145,6 +148,7 @@ export class Pipeline { | ||
| 145 | 148 | field: n.field, |
| 146 | 149 | value: n.value, |
| 147 | 150 | currency: n.currency ?? inst.currency ?? null, |
| 151 | + observationType: n.observationType, | |
| 148 | 152 | sourceTimestamp: n.sourceTimestamp, |
| 149 | 153 | timestampTrust: n.timestampTrust, |
| 150 | 154 | sequence: n.sequence ?? null, |
modified
apps/api/src/core/quotes.ts
+9 −3
@@ -50,7 +50,7 @@ export class QuoteStore { | ||
| 50 | 50 | this.flushing = true; |
| 51 | 51 | const batch = [...this.dirty.values()].sort((a, b) => a.instrumentId.localeCompare(b.instrumentId)); |
| 52 | 52 | this.dirty.clear(); |
| 53 | − const cols = 25; | |
| 53 | + const cols = 29; | |
| 54 | 54 | const tuples: string[] = []; |
| 55 | 55 | const values: unknown[] = []; |
| 56 | 56 | batch.forEach((q, i) => { |
@@ -61,18 +61,20 @@ export class QuoteStore { | ||
| 61 | 61 | q.sourceCount, q.dispersionBps, q.confidence, q.freshnessMs, q.realtimeStatus, q.rightsStatus, new Date(q.updatedAt).toISOString(), |
| 62 | 62 | q.sourceTimestamp == null ? null : new Date(q.sourceTimestamp).toISOString(), q.sessionHigh, q.sessionLow, |
| 63 | 63 | JSON.stringify(q.contributions.slice(0, 20)), q.consensusVersion, |
| 64 | + q.observationCount, q.proxyCount, q.validatorCount, q.comparability, | |
| 64 | 65 | ); |
| 65 | 66 | }); |
| 66 | 67 | try { |
| 67 | 68 | await pool.query( |
| 68 | 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 | 71 | values ${tuples.join(",")} |
| 71 | 72 | on conflict (instrument_id) do update set symbol = excluded.symbol, price = excluded.price, open = excluded.open, high = excluded.high, low = excluded.low, |
| 72 | 73 | previous_close = excluded.previous_close, change = excluded.change, change_percent = excluded.change_percent, volume = excluded.volume, bid = excluded.bid, ask = excluded.ask, |
| 73 | 74 | currency = excluded.currency, source_count = excluded.source_count, dispersion_bps = excluded.dispersion_bps, confidence = excluded.confidence, freshness_ms = excluded.freshness_ms, |
| 74 | 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 | 78 | values, |
| 77 | 79 | ); |
| 78 | 80 | } catch (err) { |
@@ -101,6 +103,10 @@ export function rowToQuote(row: any): CanonicalQuote { | ||
| 101 | 103 | ask: row.ask, |
| 102 | 104 | currency: row.currency, |
| 103 | 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 | 110 | dispersionBps: row.dispersion_bps, |
| 105 | 111 | confidence: row.confidence, |
| 106 | 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" }, | |
| 3 | − { "code": "CA", "name": "Canada", "region": "North America", "currency": "CAD" }, | |
| 4 | − { "code": "MX", "name": "Mexico", "region": "North America", "currency": "MXN" }, | |
| 5 | − { "code": "BR", "name": "Brazil", "region": "South America", "currency": "BRL" }, | |
| 6 | − { "code": "AR", "name": "Argentina", "region": "South America", "currency": "ARS" }, | |
| 7 | − { "code": "CL", "name": "Chile", "region": "South America", "currency": "CLP" }, | |
| 8 | − { "code": "CO", "name": "Colombia", "region": "South America", "currency": "COP" }, | |
| 9 | − { "code": "GB", "name": "United Kingdom", "region": "Europe", "currency": "GBP" }, | |
| 10 | − { "code": "IE", "name": "Ireland", "region": "Europe", "currency": "EUR" }, | |
| 11 | − { "code": "FR", "name": "France", "region": "Europe", "currency": "EUR" }, | |
| 12 | − { "code": "DE", "name": "Germany", "region": "Europe", "currency": "EUR" }, | |
| 13 | − { "code": "NL", "name": "Netherlands", "region": "Europe", "currency": "EUR" }, | |
| 14 | − { "code": "BE", "name": "Belgium", "region": "Europe", "currency": "EUR" }, | |
| 15 | − { "code": "PT", "name": "Portugal", "region": "Europe", "currency": "EUR" }, | |
| 16 | − { "code": "ES", "name": "Spain", "region": "Europe", "currency": "EUR" }, | |
| 17 | − { "code": "IT", "name": "Italy", "region": "Europe", "currency": "EUR" }, | |
| 18 | − { "code": "CH", "name": "Switzerland", "region": "Europe", "currency": "CHF" }, | |
| 19 | − { "code": "AT", "name": "Austria", "region": "Europe", "currency": "EUR" }, | |
| 20 | − { "code": "SE", "name": "Sweden", "region": "Europe", "currency": "SEK" }, | |
| 21 | − { "code": "NO", "name": "Norway", "region": "Europe", "currency": "NOK" }, | |
| 22 | − { "code": "DK", "name": "Denmark", "region": "Europe", "currency": "DKK" }, | |
| 23 | − { "code": "FI", "name": "Finland", "region": "Europe", "currency": "EUR" }, | |
| 24 | − { "code": "PL", "name": "Poland", "region": "Europe", "currency": "PLN" }, | |
| 25 | − { "code": "CZ", "name": "Czechia", "region": "Europe", "currency": "CZK" }, | |
| 26 | − { "code": "HU", "name": "Hungary", "region": "Europe", "currency": "HUF" }, | |
| 27 | − { "code": "GR", "name": "Greece", "region": "Europe", "currency": "EUR" }, | |
| 28 | − { "code": "TR", "name": "Türkiye", "region": "Europe", "currency": "TRY" }, | |
| 29 | − { "code": "RU", "name": "Russia", "region": "Europe", "currency": "RUB" }, | |
| 30 | − { "code": "IL", "name": "Israel", "region": "Middle East", "currency": "ILS" }, | |
| 31 | − { "code": "SA", "name": "Saudi Arabia", "region": "Middle East", "currency": "SAR" }, | |
| 32 | − { "code": "AE", "name": "United Arab Emirates", "region": "Middle East", "currency": "AED" }, | |
| 33 | − { "code": "QA", "name": "Qatar", "region": "Middle East", "currency": "QAR" }, | |
| 34 | − { "code": "EG", "name": "Egypt", "region": "Africa", "currency": "EGP" }, | |
| 35 | − { "code": "ZA", "name": "South Africa", "region": "Africa", "currency": "ZAR" }, | |
| 36 | − { "code": "NG", "name": "Nigeria", "region": "Africa", "currency": "NGN" }, | |
| 37 | − { "code": "KE", "name": "Kenya", "region": "Africa", "currency": "KES" }, | |
| 38 | − { "code": "JP", "name": "Japan", "region": "Asia-Pacific", "currency": "JPY" }, | |
| 39 | − { "code": "CN", "name": "China", "region": "Asia-Pacific", "currency": "CNY" }, | |
| 40 | − { "code": "HK", "name": "Hong Kong", "region": "Asia-Pacific", "currency": "HKD" }, | |
| 41 | − { "code": "TW", "name": "Taiwan", "region": "Asia-Pacific", "currency": "TWD" }, | |
| 42 | − { "code": "KR", "name": "South Korea", "region": "Asia-Pacific", "currency": "KRW" }, | |
| 43 | − { "code": "SG", "name": "Singapore", "region": "Asia-Pacific", "currency": "SGD" }, | |
| 44 | − { "code": "MY", "name": "Malaysia", "region": "Asia-Pacific", "currency": "MYR" }, | |
| 45 | − { "code": "TH", "name": "Thailand", "region": "Asia-Pacific", "currency": "THB" }, | |
| 46 | − { "code": "ID", "name": "Indonesia", "region": "Asia-Pacific", "currency": "IDR" }, | |
| 47 | − { "code": "PH", "name": "Philippines", "region": "Asia-Pacific", "currency": "PHP" }, | |
| 48 | − { "code": "VN", "name": "Vietnam", "region": "Asia-Pacific", "currency": "VND" }, | |
| 49 | − { "code": "IN", "name": "India", "region": "Asia-Pacific", "currency": "INR" }, | |
| 50 | − { "code": "AU", "name": "Australia", "region": "Asia-Pacific", "currency": "AUD" }, | |
| 51 | − { "code": "NZ", "name": "New Zealand", "region": "Asia-Pacific", "currency": "NZD" }, | |
| 52 | − { "code": "EU", "name": "Euro area", "region": "Europe", "currency": "EUR" }, | |
| 53 | − { "code": "XX", "name": "Global / cross-border", "region": "Global", "currency": null } | |
| 54 | −] | |
| 2 | + { | |
| 3 | + "code": "US", | |
| 4 | + "name": "United States", | |
| 5 | + "region": "North America", | |
| 6 | + "currency": "USD" | |
| 7 | + }, | |
| 8 | + { | |
| 9 | + "code": "CA", | |
| 10 | + "name": "Canada", | |
| 11 | + "region": "North America", | |
| 12 | + "currency": "CAD" | |
| 13 | + }, | |
| 14 | + { | |
| 15 | + "code": "MX", | |
| 16 | + "name": "Mexico", | |
| 17 | + "region": "North America", | |
| 18 | + "currency": "MXN" | |
| 19 | + }, | |
| 20 | + { | |
| 21 | + "code": "BR", | |
| 22 | + "name": "Brazil", | |
| 23 | + "region": "South America", | |
| 24 | + "currency": "BRL" | |
| 25 | + }, | |
| 26 | + { | |
| 27 | + "code": "AR", | |
| 28 | + "name": "Argentina", | |
| 29 | + "region": "South America", | |
| 30 | + "currency": "ARS" | |
| 31 | + }, | |
| 32 | + { | |
| 33 | + "code": "CL", | |
| 34 | + "name": "Chile", | |
| 35 | + "region": "South America", | |
| 36 | + "currency": "CLP" | |
| 37 | + }, | |
| 38 | + { | |
| 39 | + "code": "CO", | |
| 40 | + "name": "Colombia", | |
| 41 | + "region": "South America", | |
| 42 | + "currency": "COP" | |
| 43 | + }, | |
| 44 | + { | |
| 45 | + "code": "GB", | |
| 46 | + "name": "United Kingdom", | |
| 47 | + "region": "Europe", | |
| 48 | + "currency": "GBP" | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + "code": "IE", | |
| 52 | + "name": "Ireland", | |
| 53 | + "region": "Europe", | |
| 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