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 | 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