SPB Git

spb/market-atlas

Public

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

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

60 changed files +3,739 −247

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

Theme