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
Unified Split
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
@@ -1,63 +1,1732 @@
1 [ 1 [
2− { "id": "xnys", "mic": "XNYS", "name": "New York Stock Exchange", "operator": "Intercontinental Exchange", "country": "US", "city": "New York", "timezone": "America/New_York", "currency": "USD", "website": "https://www.nyse.com", "lat": 40.7069, "lon": -74.0113, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:30", "close": "16:00" }], "pre": { "open": "04:00", "close": "09:30" }, "post": { "open": "16:00", "close": "20:00" } } }, 2+ {
3− { "id": "xnas", "mic": "XNAS", "name": "Nasdaq Stock Market", "operator": "Nasdaq, Inc.", "country": "US", "city": "New York", "timezone": "America/New_York", "currency": "USD", "website": "https://www.nasdaq.com", "lat": 40.7565, "lon": -73.9860, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:30", "close": "16:00" }], "pre": { "open": "04:00", "close": "09:30" }, "post": { "open": "16:00", "close": "20:00" } } }, 3+ "id": "xnys",
4− { "id": "xase", "mic": "XASE", "name": "NYSE American", "operator": "Intercontinental Exchange", "country": "US", "city": "New York", "timezone": "America/New_York", "currency": "USD", "website": "https://www.nyse.com/markets/nyse-american", "lat": 40.7069, "lon": -74.0113, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:30", "close": "16:00" }], "pre": { "open": "04:00", "close": "09:30" }, "post": { "open": "16:00", "close": "20:00" } } }, 4+ "mic": "XNYS",
5− { "id": "arcx", "mic": "ARCX", "name": "NYSE Arca", "operator": "Intercontinental Exchange", "country": "US", "city": "Chicago", "timezone": "America/New_York", "currency": "USD", "website": "https://www.nyse.com/markets/nyse-arca", "lat": 41.8781, "lon": -87.6298, "asset_classes": ["ETF", "EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "16:00" }], "pre": { "open": "04:00", "close": "09:30" }, "post": { "open": "16:00", "close": "20:00" } } }, 5+ "name": "New York Stock Exchange",
6− { "id": "bats", "mic": "BATS", "name": "Cboe BZX Exchange", "operator": "Cboe Global Markets", "country": "US", "city": "Chicago", "timezone": "America/Chicago", "currency": "USD", "website": "https://www.cboe.com", "lat": 41.8827, "lon": -87.6324, "asset_classes": ["EQUITY", "ETF", "INDEX"], "sessions": { "regular": [{ "open": "08:30", "close": "15:00" }], "pre": { "open": "03:00", "close": "08:30" }, "post": { "open": "15:00", "close": "19:00" } } }, 6+ "operator": "Intercontinental Exchange",
7− { "id": "xcbo", "mic": "XCBO", "name": "Cboe Options Exchange", "operator": "Cboe Global Markets", "country": "US", "city": "Chicago", "timezone": "America/Chicago", "currency": "USD", "website": "https://www.cboe.com", "lat": 41.8827, "lon": -87.6324, "asset_classes": ["INDEX", "OPTION"], "sessions": { "regular": [{ "open": "08:30", "close": "15:15" }] } }, 7+ "country": "US",
8− { "id": "xcme", "mic": "XCME", "name": "CME Group (Globex)", "operator": "CME Group", "country": "US", "city": "Chicago", "timezone": "America/Chicago", "currency": "USD", "website": "https://www.cmegroup.com", "lat": 41.8785, "lon": -87.6355, "asset_classes": ["FUTURE", "COMMODITY"], "sessions": { "regular": [{ "open": "17:00", "close": "24:00" }, { "open": "00:00", "close": "16:00" }], "weekdays": [0, 1, 2, 3, 4, 5] } }, 8+ "city": "New York",
9− { "id": "xtse", "mic": "XTSE", "name": "Toronto Stock Exchange", "operator": "TMX Group", "country": "CA", "city": "Toronto", "timezone": "America/Toronto", "currency": "CAD", "website": "https://www.tsx.com", "lat": 43.6487, "lon": -79.3817, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:30", "close": "16:00" }], "post": { "open": "16:15", "close": "17:00" } } }, 9+ "timezone": "America/New_York",
10− { "id": "xmex", "mic": "XMEX", "name": "Bolsa Mexicana de Valores", "operator": "Grupo BMV", "country": "MX", "city": "Mexico City", "timezone": "America/Mexico_City", "currency": "MXN", "website": "https://www.bmv.com.mx", "lat": 19.4260, "lon": -99.1730, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "08:30", "close": "15:00" }] } }, 10+ "currency": "USD",
11− { "id": "bvmf", "mic": "BVMF", "name": "B3 — Brasil Bolsa Balcão", "operator": "B3 S.A.", "country": "BR", "city": "São Paulo", "timezone": "America/Sao_Paulo", "currency": "BRL", "website": "https://www.b3.com.br", "lat": -23.5475, "lon": -46.6361, "asset_classes": ["EQUITY", "FUTURE"], "sessions": { "regular": [{ "open": "10:00", "close": "17:55" }] } }, 11+ "website": "https://www.nyse.com",
12− { "id": "xbue", "mic": "XBUE", "name": "Bolsas y Mercados Argentinos", "operator": "BYMA", "country": "AR", "city": "Buenos Aires", "timezone": "America/Argentina/Buenos_Aires", "currency": "ARS", "website": "https://www.byma.com.ar", "lat": -34.6037, "lon": -58.3737, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "11:00", "close": "17:00" }] } }, 12+ "lat": 40.7069,
13− { "id": "xsgo", "mic": "XSGO", "name": "Bolsa de Santiago", "operator": "Bolsa de Santiago", "country": "CL", "city": "Santiago", "timezone": "America/Santiago", "currency": "CLP", "website": "https://www.bolsadesantiago.com", "lat": -33.4390, "lon": -70.6510, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "16:00" }] } }, 13+ "lon": -74.0113,
14− { "id": "xlon", "mic": "XLON", "name": "London Stock Exchange", "operator": "LSEG", "country": "GB", "city": "London", "timezone": "Europe/London", "currency": "GBP", "website": "https://www.londonstockexchange.com", "lat": 51.5155, "lon": -0.0987, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "08:00", "close": "16:30" }] } }, 14+ "asset_classes": [
15− { "id": "xdub", "mic": "XDUB", "name": "Euronext Dublin", "operator": "Euronext", "country": "IE", "city": "Dublin", "timezone": "Europe/Dublin", "currency": "EUR", "website": "https://www.euronext.com/en/markets/dublin", "lat": 53.3441, "lon": -6.2675, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "08:00", "close": "16:28" }] } }, 15+ "EQUITY",
16− { "id": "xpar", "mic": "XPAR", "name": "Euronext Paris", "operator": "Euronext", "country": "FR", "city": "Paris", "timezone": "Europe/Paris", "currency": "EUR", "website": "https://www.euronext.com/en/markets/paris", "lat": 48.8698, "lon": 2.3397, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, 16+ "ETF"
17− { "id": "xams", "mic": "XAMS", "name": "Euronext Amsterdam", "operator": "Euronext", "country": "NL", "city": "Amsterdam", "timezone": "Europe/Amsterdam", "currency": "EUR", "website": "https://www.euronext.com/en/markets/amsterdam", "lat": 52.3702, "lon": 4.8952, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, 17+ ],
18− { "id": "xbru", "mic": "XBRU", "name": "Euronext Brussels", "operator": "Euronext", "country": "BE", "city": "Brussels", "timezone": "Europe/Brussels", "currency": "EUR", "website": "https://www.euronext.com/en/markets/brussels", "lat": 50.8467, "lon": 4.3499, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, 18+ "sessions": {
19− { "id": "xlis", "mic": "XLIS", "name": "Euronext Lisbon", "operator": "Euronext", "country": "PT", "city": "Lisbon", "timezone": "Europe/Lisbon", "currency": "EUR", "website": "https://www.euronext.com/en/markets/lisbon", "lat": 38.7078, "lon": -9.1366, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "08:00", "close": "16:30" }] } }, 19+ "regular": [
20− { "id": "xmad", "mic": "XMAD", "name": "Bolsa de Madrid", "operator": "SIX / BME", "country": "ES", "city": "Madrid", "timezone": "Europe/Madrid", "currency": "EUR", "website": "https://www.bolsamadrid.es", "lat": 40.4168, "lon": -3.6936, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, 20+ {
21− { "id": "xmil", "mic": "XMIL", "name": "Borsa Italiana", "operator": "Euronext", "country": "IT", "city": "Milan", "timezone": "Europe/Rome", "currency": "EUR", "website": "https://www.borsaitaliana.it", "lat": 45.4654, "lon": 9.1859, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, 21+ "open": "09:30",
22− { "id": "xetr", "mic": "XETR", "name": "Xetra (Frankfurt)", "operator": "Deutsche Börse", "country": "DE", "city": "Frankfurt", "timezone": "Europe/Berlin", "currency": "EUR", "website": "https://www.xetra.com", "lat": 50.1155, "lon": 8.6742, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, 22+ "close": "16:00"
23− { "id": "xswx", "mic": "XSWX", "name": "SIX Swiss Exchange", "operator": "SIX Group", "country": "CH", "city": "Zurich", "timezone": "Europe/Zurich", "currency": "CHF", "website": "https://www.six-group.com", "lat": 47.3667, "lon": 8.5378, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, 23+ }
24− { "id": "xwbo", "mic": "XWBO", "name": "Wiener Börse", "operator": "Wiener Börse AG", "country": "AT", "city": "Vienna", "timezone": "Europe/Vienna", "currency": "EUR", "website": "https://www.wienerborse.at", "lat": 48.2116, "lon": 16.3702, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, 24+ ],
25− { "id": "xsto", "mic": "XSTO", "name": "Nasdaq Stockholm", "operator": "Nasdaq Nordic", "country": "SE", "city": "Stockholm", "timezone": "Europe/Stockholm", "currency": "SEK", "website": "https://www.nasdaqomxnordic.com", "lat": 59.3300, "lon": 18.0700, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, 25+ "pre": {
26− { "id": "xcse", "mic": "XCSE", "name": "Nasdaq Copenhagen", "operator": "Nasdaq Nordic", "country": "DK", "city": "Copenhagen", "timezone": "Europe/Copenhagen", "currency": "DKK", "website": "https://www.nasdaqomxnordic.com", "lat": 55.6761, "lon": 12.5683, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:00" }] } }, 26+ "open": "04:00",
27− { "id": "xhel", "mic": "XHEL", "name": "Nasdaq Helsinki", "operator": "Nasdaq Nordic", "country": "FI", "city": "Helsinki", "timezone": "Europe/Helsinki", "currency": "EUR", "website": "https://www.nasdaqomxnordic.com", "lat": 60.1699, "lon": 24.9384, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "18:30" }] } }, 27+ "close": "09:30"
28− { "id": "xosl", "mic": "XOSL", "name": "Oslo Børs", "operator": "Euronext", "country": "NO", "city": "Oslo", "timezone": "Europe/Oslo", "currency": "NOK", "website": "https://www.euronext.com/en/markets/oslo", "lat": 59.9139, "lon": 10.7522, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "16:20" }] } }, 28+ },
29− { "id": "xwar", "mic": "XWAR", "name": "Warsaw Stock Exchange", "operator": "GPW", "country": "PL", "city": "Warsaw", "timezone": "Europe/Warsaw", "currency": "PLN", "website": "https://www.gpw.pl", "lat": 52.2297, "lon": 21.0122, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:00" }] } }, 29+ "post": {
30− { "id": "xpra", "mic": "XPRA", "name": "Prague Stock Exchange", "operator": "Wiener Börse AG", "country": "CZ", "city": "Prague", "timezone": "Europe/Prague", "currency": "CZK", "website": "https://www.pse.cz", "lat": 50.0755, "lon": 14.4378, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "16:20" }] } }, 30+ "open": "16:00",
31− { "id": "xbud", "mic": "XBUD", "name": "Budapest Stock Exchange", "operator": "BSE", "country": "HU", "city": "Budapest", "timezone": "Europe/Budapest", "currency": "HUF", "website": "https://www.bse.hu", "lat": 47.4979, "lon": 19.0402, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:00" }] } }, 31+ "close": "20:00"
32− { "id": "xath", "mic": "XATH", "name": "Athens Stock Exchange", "operator": "ATHEX", "country": "GR", "city": "Athens", "timezone": "Europe/Athens", "currency": "EUR", "website": "https://www.athexgroup.gr", "lat": 37.9838, "lon": 23.7275, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "17:20" }] } }, 32+ }
33− { "id": "xist", "mic": "XIST", "name": "Borsa İstanbul", "operator": "Borsa İstanbul", "country": "TR", "city": "Istanbul", "timezone": "Europe/Istanbul", "currency": "TRY", "website": "https://www.borsaistanbul.com", "lat": 41.1085, "lon": 29.0288, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "18:00" }] } }, 33+ }
34− { "id": "xmos", "mic": "MISX", "name": "Moscow Exchange", "operator": "MOEX", "country": "RU", "city": "Moscow", "timezone": "Europe/Moscow", "currency": "RUB", "website": "https://www.moex.com", "lat": 55.7558, "lon": 37.6173, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "18:50" }] } }, 34+ },
35− { "id": "xtae", "mic": "XTAE", "name": "Tel Aviv Stock Exchange", "operator": "TASE", "country": "IL", "city": "Tel Aviv", "timezone": "Asia/Jerusalem", "currency": "ILS", "website": "https://www.tase.co.il", "lat": 32.0636, "lon": 34.7708, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "17:15" }], "weekdays": [0, 1, 2, 3, 4] } }, 35+ {
36− { "id": "xsau", "mic": "XSAU", "name": "Saudi Exchange (Tadawul)", "operator": "Saudi Tadawul Group", "country": "SA", "city": "Riyadh", "timezone": "Asia/Riyadh", "currency": "SAR", "website": "https://www.saudiexchange.sa", "lat": 24.7136, "lon": 46.6753, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "15:00" }], "weekdays": [0, 1, 2, 3, 4] } }, 36+ "id": "xnas",
37− { "id": "xdfm", "mic": "XDFM", "name": "Dubai Financial Market", "operator": "DFM", "country": "AE", "city": "Dubai", "timezone": "Asia/Dubai", "currency": "AED", "website": "https://www.dfm.ae", "lat": 25.2172, "lon": 55.2790, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "14:45" }] } }, 37+ "mic": "XNAS",
38− { "id": "dsmd", "mic": "DSMD", "name": "Qatar Stock Exchange", "operator": "QSE", "country": "QA", "city": "Doha", "timezone": "Asia/Qatar", "currency": "QAR", "website": "https://www.qe.com.qa", "lat": 25.2854, "lon": 51.5310, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "13:15" }], "weekdays": [0, 1, 2, 3, 4] } }, 38+ "name": "Nasdaq Stock Market",
39− { "id": "xcai", "mic": "XCAI", "name": "Egyptian Exchange", "operator": "EGX", "country": "EG", "city": "Cairo", "timezone": "Africa/Cairo", "currency": "EGP", "website": "https://www.egx.com.eg", "lat": 30.0444, "lon": 31.2357, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "14:30" }], "weekdays": [0, 1, 2, 3, 4] } }, 39+ "operator": "Nasdaq, Inc.",
40− { "id": "xjse", "mic": "XJSE", "name": "Johannesburg Stock Exchange", "operator": "JSE Limited", "country": "ZA", "city": "Johannesburg", "timezone": "Africa/Johannesburg", "currency": "ZAR", "website": "https://www.jse.co.za", "lat": -26.1076, "lon": 28.0567, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:00" }] } }, 40+ "country": "US",
41− { "id": "xnsa", "mic": "XNSA", "name": "Nigerian Exchange", "operator": "NGX Group", "country": "NG", "city": "Lagos", "timezone": "Africa/Lagos", "currency": "NGN", "website": "https://ngxgroup.com", "lat": 6.4541, "lon": 3.3947, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "14:30" }] } }, 41+ "city": "New York",
42− { "id": "xnai", "mic": "XNAI", "name": "Nairobi Securities Exchange", "operator": "NSE", "country": "KE", "city": "Nairobi", "timezone": "Africa/Nairobi", "currency": "KES", "website": "https://www.nse.co.ke", "lat": -1.2921, "lon": 36.8219, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "15:00" }] } }, 42+ "timezone": "America/New_York",
43− { "id": "xnse", "mic": "XNSE", "name": "National Stock Exchange of India", "operator": "NSE India", "country": "IN", "city": "Mumbai", "timezone": "Asia/Kolkata", "currency": "INR", "website": "https://www.nseindia.com", "lat": 19.0607, "lon": 72.8611, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:15", "close": "15:30" }] } }, 43+ "currency": "USD",
44− { "id": "xbom", "mic": "XBOM", "name": "BSE (Bombay Stock Exchange)", "operator": "BSE Ltd", "country": "IN", "city": "Mumbai", "timezone": "Asia/Kolkata", "currency": "INR", "website": "https://www.bseindia.com", "lat": 18.9299, "lon": 72.8336, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:15", "close": "15:30" }] } }, 44+ "website": "https://www.nasdaq.com",
45− { "id": "xshg", "mic": "XSHG", "name": "Shanghai Stock Exchange", "operator": "SSE", "country": "CN", "city": "Shanghai", "timezone": "Asia/Shanghai", "currency": "CNY", "website": "https://www.sse.com.cn", "lat": 31.2304, "lon": 121.4737, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "11:30" }, { "open": "13:00", "close": "15:00" }] } }, 45+ "lat": 40.7565,
46− { "id": "xshe", "mic": "XSHE", "name": "Shenzhen Stock Exchange", "operator": "SZSE", "country": "CN", "city": "Shenzhen", "timezone": "Asia/Shanghai", "currency": "CNY", "website": "https://www.szse.cn", "lat": 22.5431, "lon": 114.0579, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "11:30" }, { "open": "13:00", "close": "15:00" }] } }, 46+ "lon": -73.986,
47− { "id": "xhkg", "mic": "XHKG", "name": "Hong Kong Stock Exchange", "operator": "HKEX", "country": "HK", "city": "Hong Kong", "timezone": "Asia/Hong_Kong", "currency": "HKD", "website": "https://www.hkex.com.hk", "lat": 22.2830, "lon": 114.1588, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:30", "close": "12:00" }, { "open": "13:00", "close": "16:00" }] } }, 47+ "asset_classes": [
48− { "id": "xtai", "mic": "XTAI", "name": "Taiwan Stock Exchange", "operator": "TWSE", "country": "TW", "city": "Taipei", "timezone": "Asia/Taipei", "currency": "TWD", "website": "https://www.twse.com.tw", "lat": 25.0330, "lon": 121.5654, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "13:30" }] } }, 48+ "EQUITY",
49− { "id": "xkrx", "mic": "XKRX", "name": "Korea Exchange", "operator": "KRX", "country": "KR", "city": "Seoul", "timezone": "Asia/Seoul", "currency": "KRW", "website": "https://global.krx.co.kr", "lat": 37.5236, "lon": 126.9260, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "15:30" }] } }, 49+ "ETF"
50− { "id": "xtks", "mic": "XTKS", "name": "Tokyo Stock Exchange", "operator": "Japan Exchange Group", "country": "JP", "city": "Tokyo", "timezone": "Asia/Tokyo", "currency": "JPY", "website": "https://www.jpx.co.jp", "lat": 35.6817, "lon": 139.7785, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:00", "close": "11:30" }, { "open": "12:30", "close": "15:30" }] } }, 50+ ],
51− { "id": "xses", "mic": "XSES", "name": "Singapore Exchange", "operator": "SGX Group", "country": "SG", "city": "Singapore", "timezone": "Asia/Singapore", "currency": "SGD", "website": "https://www.sgx.com", "lat": 1.2792, "lon": 103.8507, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "12:00" }, { "open": "13:00", "close": "17:00" }] } }, 51+ "sessions": {
52− { "id": "xkls", "mic": "XKLS", "name": "Bursa Malaysia", "operator": "Bursa Malaysia", "country": "MY", "city": "Kuala Lumpur", "timezone": "Asia/Kuala_Lumpur", "currency": "MYR", "website": "https://www.bursamalaysia.com", "lat": 3.1490, "lon": 101.6959, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "12:30" }, { "open": "14:30", "close": "17:00" }] } }, 52+ "regular": [
53− { "id": "xbkk", "mic": "XBKK", "name": "Stock Exchange of Thailand", "operator": "SET", "country": "TH", "city": "Bangkok", "timezone": "Asia/Bangkok", "currency": "THB", "website": "https://www.set.or.th", "lat": 13.7563, "lon": 100.5018, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "12:30" }, { "open": "14:30", "close": "16:30" }] } }, 53+ {
54− { "id": "xidx", "mic": "XIDX", "name": "Indonesia Stock Exchange", "operator": "IDX", "country": "ID", "city": "Jakarta", "timezone": "Asia/Jakarta", "currency": "IDR", "website": "https://www.idx.co.id", "lat": -6.2245, "lon": 106.8090, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "11:30" }, { "open": "13:30", "close": "15:50" }] } }, 54+ "open": "09:30",
55− { "id": "xphs", "mic": "XPHS", "name": "Philippine Stock Exchange", "operator": "PSE", "country": "PH", "city": "Manila", "timezone": "Asia/Manila", "currency": "PHP", "website": "https://www.pse.com.ph", "lat": 14.5547, "lon": 121.0244, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "12:00" }, { "open": "13:00", "close": "15:00" }] } }, 55+ "close": "16:00"
56− { "id": "xstc", "mic": "XSTC", "name": "Ho Chi Minh Stock Exchange", "operator": "HOSE", "country": "VN", "city": "Ho Chi Minh City", "timezone": "Asia/Ho_Chi_Minh", "currency": "VND", "website": "https://www.hsx.vn", "lat": 10.7769, "lon": 106.7009, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "11:30" }, { "open": "13:00", "close": "15:00" }] } }, 56+ }
57− { "id": "xasx", "mic": "XASX", "name": "Australian Securities Exchange", "operator": "ASX Ltd", "country": "AU", "city": "Sydney", "timezone": "Australia/Sydney", "currency": "AUD", "website": "https://www.asx.com.au", "lat": -33.8651, "lon": 151.2099, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "10:00", "close": "16:00" }] } }, 57+ ],
58− { "id": "xnze", "mic": "XNZE", "name": "NZX", "operator": "NZX Limited", "country": "NZ", "city": "Wellington", "timezone": "Pacific/Auckland", "currency": "NZD", "website": "https://www.nzx.com", "lat": -41.2865, "lon": 174.7762, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "16:45" }] } }, 58+ "pre": {
59− { "id": "coinbase", "mic": null, "name": "Coinbase Exchange", "operator": "Coinbase Global", "country": "US", "city": "San Francisco", "timezone": "UTC", "currency": "USD", "website": "https://exchange.coinbase.com", "lat": 37.7749, "lon": -122.4194, "asset_classes": ["CRYPTO"], "sessions": { "regular": [], "continuous": true } }, 59+ "open": "04:00",
60− { "id": "kraken", "mic": null, "name": "Kraken", "operator": "Payward, Inc.", "country": "US", "city": "San Francisco", "timezone": "UTC", "currency": "USD", "website": "https://www.kraken.com", "lat": 37.79, "lon": -122.40, "asset_classes": ["CRYPTO"], "sessions": { "regular": [], "continuous": true } }, 60+ "close": "09:30"
61− { "id": "binance", "mic": null, "name": "Binance", "operator": "Binance", "country": "XX", "city": null, "timezone": "UTC", "currency": "USDT", "website": "https://www.binance.com", "lat": 43.7384, "lon": 7.4246, "asset_classes": ["CRYPTO"], "sessions": { "regular": [], "continuous": true } }, 61+ },
62− { "id": "okx", "mic": null, "name": "OKX", "operator": "OKX", "country": "XX", "city": null, "timezone": "UTC", "currency": "USDT", "website": "https://www.okx.com", "lat": 1.3521, "lon": 103.8198, "asset_classes": ["CRYPTO"], "sessions": { "regular": [], "continuous": true } } 62+ "post": {
63−] 63+ "open": "16:00",
64+ "close": "20:00"
65+ }
66+ }
67+ },
68+ {
69+ "id": "xase",
70+ "mic": "XASE",
71+ "name": "NYSE American",
72+ "operator": "Intercontinental Exchange",
73+ "country": "US",
74+ "city": "New York",
75+ "timezone": "America/New_York",
76+ "currency": "USD",
77+ "website": "https://www.nyse.com/markets/nyse-american",
78+ "lat": 40.7069,
79+ "lon": -74.0113,
80+ "asset_classes": [
81+ "EQUITY",
82+ "ETF"
83+ ],
84+ "sessions": {
85+ "regular": [
86+ {
87+ "open": "09:30",
88+ "close": "16:00"
89+ }
90+ ],
91+ "pre": {
92+ "open": "04:00",
93+ "close": "09:30"
94+ },
95+ "post": {
96+ "open": "16:00",
97+ "close": "20:00"
98+ }
99+ }
100+ },
101+ {
102+ "id": "arcx",
103+ "mic": "ARCX",
104+ "name": "NYSE Arca",
105+ "operator": "Intercontinental Exchange",
106+ "country": "US",
107+ "city": "Chicago",
108+ "timezone": "America/New_York",
109+ "currency": "USD",
110+ "website": "https://www.nyse.com/markets/nyse-arca",
111+ "lat": 41.8781,
112+ "lon": -87.6298,
113+ "asset_classes": [
114+ "ETF",
115+ "EQUITY"
116+ ],
117+ "sessions": {
118+ "regular": [
119+ {
120+ "open": "09:30",
121+ "close": "16:00"
122+ }
123+ ],
124+ "pre": {
125+ "open": "04:00",
126+ "close": "09:30"
127+ },
128+ "post": {
129+ "open": "16:00",
130+ "close": "20:00"
131+ }
132+ }
133+ },
134+ {
135+ "id": "bats",
136+ "mic": "BATS",
137+ "name": "Cboe BZX Exchange",
138+ "operator": "Cboe Global Markets",
139+ "country": "US",
140+ "city": "Chicago",
141+ "timezone": "America/Chicago",
142+ "currency": "USD",
143+ "website": "https://www.cboe.com",
144+ "lat": 41.8827,
145+ "lon": -87.6324,
146+ "asset_classes": [
147+ "EQUITY",
148+ "ETF",
149+ "INDEX"
150+ ],
151+ "sessions": {
152+ "regular": [
153+ {
154+ "open": "08:30",
155+ "close": "15:00"
156+ }
157+ ],
158+ "pre": {
159+ "open": "03:00",
160+ "close": "08:30"
161+ },
162+ "post": {
163+ "open": "15:00",
164+ "close": "19:00"
165+ }
166+ }
167+ },
168+ {
169+ "id": "xcbo",
170+ "mic": "XCBO",
171+ "name": "Cboe Options Exchange",
172+ "operator": "Cboe Global Markets",
173+ "country": "US",
174+ "city": "Chicago",
175+ "timezone": "America/Chicago",
176+ "currency": "USD",
177+ "website": "https://www.cboe.com",
178+ "lat": 41.8827,
179+ "lon": -87.6324,
180+ "asset_classes": [
181+ "INDEX",
182+ "OPTION"
183+ ],
184+ "sessions": {
185+ "regular": [
186+ {
187+ "open": "08:30",
188+ "close": "15:15"
189+ }
190+ ]
191+ }
192+ },
193+ {
194+ "id": "xcme",
195+ "mic": "XCME",
196+ "name": "CME Group (Globex)",
197+ "operator": "CME Group",
198+ "country": "US",
199+ "city": "Chicago",
200+ "timezone": "America/Chicago",
201+ "currency": "USD",
202+ "website": "https://www.cmegroup.com",
203+ "lat": 41.8785,
204+ "lon": -87.6355,
205+ "asset_classes": [
206+ "FUTURE",
207+ "COMMODITY"
208+ ],
209+ "sessions": {
210+ "regular": [
211+ {
212+ "open": "17:00",
213+ "close": "24:00"
214+ },
215+ {
216+ "open": "00:00",
217+ "close": "16:00"
218+ }
219+ ],
220+ "weekdays": [
221+ 0,
222+ 1,
223+ 2,
224+ 3,
225+ 4,
226+ 5
227+ ]
228+ }
229+ },
230+ {
231+ "id": "xtse",
232+ "mic": "XTSE",
233+ "name": "Toronto Stock Exchange",
234+ "operator": "TMX Group",
235+ "country": "CA",
236+ "city": "Toronto",
237+ "timezone": "America/Toronto",
238+ "currency": "CAD",
239+ "website": "https://www.tsx.com",
240+ "lat": 43.6487,
241+ "lon": -79.3817,
242+ "asset_classes": [
243+ "EQUITY",
244+ "ETF"
245+ ],
246+ "sessions": {
247+ "regular": [
248+ {
249+ "open": "09:30",
250+ "close": "16:00"
251+ }
252+ ],
253+ "post": {
254+ "open": "16:15",
255+ "close": "17:00"
256+ }
257+ }
258+ },
259+ {
260+ "id": "xmex",
261+ "mic": "XMEX",
262+ "name": "Bolsa Mexicana de Valores",
263+ "operator": "Grupo BMV",
264+ "country": "MX",
265+ "city": "Mexico City",
266+ "timezone": "America/Mexico_City",
267+ "currency": "MXN",
268+ "website": "https://www.bmv.com.mx",
269+ "lat": 19.426,
270+ "lon": -99.173,
271+ "asset_classes": [
272+ "EQUITY"
273+ ],
274+ "sessions": {
275+ "regular": [
276+ {
277+ "open": "08:30",
278+ "close": "15:00"
279+ }
280+ ]
281+ }
282+ },
283+ {
284+ "id": "bvmf",
285+ "mic": "BVMF",
286+ "name": "B3 — Brasil Bolsa Balcão",
287+ "operator": "B3 S.A.",
288+ "country": "BR",
289+ "city": "São Paulo",
290+ "timezone": "America/Sao_Paulo",
291+ "currency": "BRL",
292+ "website": "https://www.b3.com.br",
293+ "lat": -23.5475,
294+ "lon": -46.6361,
295+ "asset_classes": [
296+ "EQUITY",
297+ "FUTURE"
298+ ],
299+ "sessions": {
300+ "regular": [
301+ {
302+ "open": "10:00",
303+ "close": "17:55"
304+ }
305+ ]
306+ }
307+ },
308+ {
309+ "id": "xbue",
310+ "mic": "XBUE",
311+ "name": "Bolsas y Mercados Argentinos",
312+ "operator": "BYMA",
313+ "country": "AR",
314+ "city": "Buenos Aires",
315+ "timezone": "America/Argentina/Buenos_Aires",
316+ "currency": "ARS",
317+ "website": "https://www.byma.com.ar",
318+ "lat": -34.6037,
319+ "lon": -58.3737,
320+ "asset_classes": [
321+ "EQUITY"
322+ ],
323+ "sessions": {
324+ "regular": [
325+ {
326+ "open": "11:00",
327+ "close": "17:00"
328+ }
329+ ]
330+ }
331+ },
332+ {
333+ "id": "xsgo",
334+ "mic": "XSGO",
335+ "name": "Bolsa de Santiago",
336+ "operator": "Bolsa de Santiago",
337+ "country": "CL",
338+ "city": "Santiago",
339+ "timezone": "America/Santiago",
340+ "currency": "CLP",
341+ "website": "https://www.bolsadesantiago.com",
342+ "lat": -33.439,
343+ "lon": -70.651,
344+ "asset_classes": [
345+ "EQUITY"
346+ ],
347+ "sessions": {
348+ "regular": [
349+ {
350+ "open": "09:30",
351+ "close": "16:00"
352+ }
353+ ]
354+ }
355+ },
356+ {
357+ "id": "xlon",
358+ "mic": "XLON",
359+ "name": "London Stock Exchange",
360+ "operator": "LSEG",
361+ "country": "GB",
362+ "city": "London",
363+ "timezone": "Europe/London",
364+ "currency": "GBP",
365+ "website": "https://www.londonstockexchange.com",
366+ "lat": 51.5155,
367+ "lon": -0.0987,
368+ "asset_classes": [
369+ "EQUITY",
370+ "ETF"
371+ ],
372+ "sessions": {
373+ "regular": [
374+ {
375+ "open": "08:00",
376+ "close": "16:30"
377+ }
378+ ]
379+ }
380+ },
381+ {
382+ "id": "xdub",
383+ "mic": "XDUB",
384+ "name": "Euronext Dublin",
385+ "operator": "Euronext",
386+ "country": "IE",
387+ "city": "Dublin",
388+ "timezone": "Europe/Dublin",
389+ "currency": "EUR",
390+ "website": "https://www.euronext.com/en/markets/dublin",
391+ "lat": 53.3441,
392+ "lon": -6.2675,
393+ "asset_classes": [
394+ "EQUITY"
395+ ],
396+ "sessions": {
397+ "regular": [
398+ {
399+ "open": "08:00",
400+ "close": "16:28"
401+ }
402+ ]
403+ }
404+ },
405+ {
406+ "id": "xpar",
407+ "mic": "XPAR",
408+ "name": "Euronext Paris",
409+ "operator": "Euronext",
410+ "country": "FR",
411+ "city": "Paris",
412+ "timezone": "Europe/Paris",
413+ "currency": "EUR",
414+ "website": "https://www.euronext.com/en/markets/paris",
415+ "lat": 48.8698,
416+ "lon": 2.3397,
417+ "asset_classes": [
418+ "EQUITY",
419+ "ETF"
420+ ],
421+ "sessions": {
422+ "regular": [
423+ {
424+ "open": "09:00",
425+ "close": "17:30"
426+ }
427+ ]
428+ }
429+ },
430+ {
431+ "id": "xams",
432+ "mic": "XAMS",
433+ "name": "Euronext Amsterdam",
434+ "operator": "Euronext",
435+ "country": "NL",
436+ "city": "Amsterdam",
437+ "timezone": "Europe/Amsterdam",
438+ "currency": "EUR",
439+ "website": "https://www.euronext.com/en/markets/amsterdam",
440+ "lat": 52.3702,
441+ "lon": 4.8952,
442+ "asset_classes": [
443+ "EQUITY",
444+ "ETF"
445+ ],
446+ "sessions": {
447+ "regular": [
448+ {
449+ "open": "09:00",
450+ "close": "17:30"
451+ }
452+ ]
453+ }
454+ },
455+ {
456+ "id": "xbru",
457+ "mic": "XBRU",
458+ "name": "Euronext Brussels",
459+ "operator": "Euronext",
460+ "country": "BE",
461+ "city": "Brussels",
462+ "timezone": "Europe/Brussels",
463+ "currency": "EUR",
464+ "website": "https://www.euronext.com/en/markets/brussels",
465+ "lat": 50.8467,
466+ "lon": 4.3499,
467+ "asset_classes": [
468+ "EQUITY"
469+ ],
470+ "sessions": {
471+ "regular": [
472+ {
473+ "open": "09:00",
474+ "close": "17:30"
475+ }
476+ ]
477+ }
478+ },
479+ {
480+ "id": "xlis",
481+ "mic": "XLIS",
482+ "name": "Euronext Lisbon",
483+ "operator": "Euronext",
484+ "country": "PT",
485+ "city": "Lisbon",
486+ "timezone": "Europe/Lisbon",
487+ "currency": "EUR",
488+ "website": "https://www.euronext.com/en/markets/lisbon",
489+ "lat": 38.7078,
490+ "lon": -9.1366,
491+ "asset_classes": [
492+ "EQUITY"
493+ ],
494+ "sessions": {
495+ "regular": [
496+ {
497+ "open": "08:00",
498+ "close": "16:30"
499+ }
500+ ]
501+ }
502+ },
503+ {
504+ "id": "xmad",
505+ "mic": "XMAD",
506+ "name": "Bolsa de Madrid",
507+ "operator": "SIX / BME",
508+ "country": "ES",
509+ "city": "Madrid",
510+ "timezone": "Europe/Madrid",
511+ "currency": "EUR",
512+ "website": "https://www.bolsamadrid.es",
513+ "lat": 40.4168,
514+ "lon": -3.6936,
515+ "asset_classes": [
516+ "EQUITY"
517+ ],
518+ "sessions": {
519+ "regular": [
520+ {
521+ "open": "09:00",
522+ "close": "17:30"
523+ }
524+ ]
525+ }
526+ },
527+ {
528+ "id": "xmil",
529+ "mic": "XMIL",
530+ "name": "Borsa Italiana",
531+ "operator": "Euronext",
532+ "country": "IT",
533+ "city": "Milan",
534+ "timezone": "Europe/Rome",
535+ "currency": "EUR",
536+ "website": "https://www.borsaitaliana.it",
537+ "lat": 45.4654,
538+ "lon": 9.1859,
539+ "asset_classes": [
540+ "EQUITY"
541+ ],
542+ "sessions": {
543+ "regular": [
544+ {
545+ "open": "09:00",
546+ "close": "17:30"
547+ }
548+ ]
549+ }
550+ },
551+ {
552+ "id": "xetr",
553+ "mic": "XETR",
554+ "name": "Xetra (Frankfurt)",
555+ "operator": "Deutsche Börse",
556+ "country": "DE",
557+ "city": "Frankfurt",
558+ "timezone": "Europe/Berlin",
559+ "currency": "EUR",
560+ "website": "https://www.xetra.com",
561+ "lat": 50.1155,
562+ "lon": 8.6742,
563+ "asset_classes": [
564+ "EQUITY",
565+ "ETF"
566+ ],
567+ "sessions": {
568+ "regular": [
569+ {
570+ "open": "09:00",
571+ "close": "17:30"
572+ }
573+ ]
574+ }
575+ },
576+ {
577+ "id": "xswx",
578+ "mic": "XSWX",
579+ "name": "SIX Swiss Exchange",
580+ "operator": "SIX Group",
581+ "country": "CH",
582+ "city": "Zurich",
583+ "timezone": "Europe/Zurich",
584+ "currency": "CHF",
585+ "website": "https://www.six-group.com",
586+ "lat": 47.3667,
587+ "lon": 8.5378,
588+ "asset_classes": [
589+ "EQUITY",
590+ "ETF"
591+ ],
592+ "sessions": {
593+ "regular": [
594+ {
595+ "open": "09:00",
596+ "close": "17:30"
597+ }
598+ ]
599+ }
600+ },
601+ {
602+ "id": "xwbo",
603+ "mic": "XWBO",
604+ "name": "Wiener Börse",
605+ "operator": "Wiener Börse AG",
606+ "country": "AT",
607+ "city": "Vienna",
608+ "timezone": "Europe/Vienna",
609+ "currency": "EUR",
610+ "website": "https://www.wienerborse.at",
611+ "lat": 48.2116,
612+ "lon": 16.3702,
613+ "asset_classes": [
614+ "EQUITY"
615+ ],
616+ "sessions": {
617+ "regular": [
618+ {
619+ "open": "09:00",
620+ "close": "17:30"
621+ }
622+ ]
623+ }
624+ },
625+ {
626+ "id": "xsto",
627+ "mic": "XSTO",
628+ "name": "Nasdaq Stockholm",
629+ "operator": "Nasdaq Nordic",
630+ "country": "SE",
631+ "city": "Stockholm",
632+ "timezone": "Europe/Stockholm",
633+ "currency": "SEK",
634+ "website": "https://www.nasdaqomxnordic.com",
635+ "lat": 59.33,
636+ "lon": 18.07,
637+ "asset_classes": [
638+ "EQUITY"
639+ ],
640+ "sessions": {
641+ "regular": [
642+ {
643+ "open": "09:00",
644+ "close": "17:30"
645+ }
646+ ]
647+ }
648+ },
649+ {
650+ "id": "xcse",
651+ "mic": "XCSE",
652+ "name": "Nasdaq Copenhagen",
653+ "operator": "Nasdaq Nordic",
654+ "country": "DK",
655+ "city": "Copenhagen",
656+ "timezone": "Europe/Copenhagen",
657+ "currency": "DKK",
658+ "website": "https://www.nasdaqomxnordic.com",
659+ "lat": 55.6761,
660+ "lon": 12.5683,
661+ "asset_classes": [
662+ "EQUITY"
663+ ],
664+ "sessions": {
665+ "regular": [
666+ {
667+ "open": "09:00",
668+ "close": "17:00"
669+ }
670+ ]
671+ }
672+ },
673+ {
674+ "id": "xhel",
675+ "mic": "XHEL",
676+ "name": "Nasdaq Helsinki",
677+ "operator": "Nasdaq Nordic",
678+ "country": "FI",
679+ "city": "Helsinki",
680+ "timezone": "Europe/Helsinki",
681+ "currency": "EUR",
682+ "website": "https://www.nasdaqomxnordic.com",
683+ "lat": 60.1699,
684+ "lon": 24.9384,
685+ "asset_classes": [
686+ "EQUITY"
687+ ],
688+ "sessions": {
689+ "regular": [
690+ {
691+ "open": "10:00",
692+ "close": "18:30"
693+ }
694+ ]
695+ }
696+ },
697+ {
698+ "id": "xosl",
699+ "mic": "XOSL",
700+ "name": "Oslo Børs",
701+ "operator": "Euronext",
702+ "country": "NO",
703+ "city": "Oslo",
704+ "timezone": "Europe/Oslo",
705+ "currency": "NOK",
706+ "website": "https://www.euronext.com/en/markets/oslo",
707+ "lat": 59.9139,
708+ "lon": 10.7522,
709+ "asset_classes": [
710+ "EQUITY"
711+ ],
712+ "sessions": {
713+ "regular": [
714+ {
715+ "open": "09:00",
716+ "close": "16:20"
717+ }
718+ ]
719+ }
720+ },
721+ {
722+ "id": "xwar",
723+ "mic": "XWAR",
724+ "name": "Warsaw Stock Exchange",
725+ "operator": "GPW",
726+ "country": "PL",
727+ "city": "Warsaw",
728+ "timezone": "Europe/Warsaw",
729+ "currency": "PLN",
730+ "website": "https://www.gpw.pl",
731+ "lat": 52.2297,
732+ "lon": 21.0122,
733+ "asset_classes": [
734+ "EQUITY"
735+ ],
736+ "sessions": {
737+ "regular": [
738+ {
739+ "open": "09:00",
740+ "close": "17:00"
741+ }
742+ ]
743+ }
744+ },
745+ {
746+ "id": "xpra",
747+ "mic": "XPRA",
748+ "name": "Prague Stock Exchange",
749+ "operator": "Wiener Börse AG",
750+ "country": "CZ",
751+ "city": "Prague",
752+ "timezone": "Europe/Prague",
753+ "currency": "CZK",
754+ "website": "https://www.pse.cz",
755+ "lat": 50.0755,
756+ "lon": 14.4378,
757+ "asset_classes": [
758+ "EQUITY"
759+ ],
760+ "sessions": {
761+ "regular": [
762+ {
763+ "open": "09:00",
764+ "close": "16:20"
765+ }
766+ ]
767+ }
768+ },
769+ {
770+ "id": "xbud",
771+ "mic": "XBUD",
772+ "name": "Budapest Stock Exchange",
773+ "operator": "BSE",
774+ "country": "HU",
775+ "city": "Budapest",
776+ "timezone": "Europe/Budapest",
777+ "currency": "HUF",
778+ "website": "https://www.bse.hu",
779+ "lat": 47.4979,
780+ "lon": 19.0402,
781+ "asset_classes": [
782+ "EQUITY"
783+ ],
784+ "sessions": {
785+ "regular": [
786+ {
787+ "open": "09:00",
788+ "close": "17:00"
789+ }
790+ ]
791+ }
792+ },
793+ {
794+ "id": "xath",
795+ "mic": "XATH",
796+ "name": "Athens Stock Exchange",
797+ "operator": "ATHEX",
798+ "country": "GR",
799+ "city": "Athens",
800+ "timezone": "Europe/Athens",
801+ "currency": "EUR",
802+ "website": "https://www.athexgroup.gr",
803+ "lat": 37.9838,
804+ "lon": 23.7275,
805+ "asset_classes": [
806+ "EQUITY"
807+ ],
808+ "sessions": {
809+ "regular": [
810+ {
811+ "open": "10:00",
812+ "close": "17:20"
813+ }
814+ ]
815+ }
816+ },
817+ {
818+ "id": "xist",
819+ "mic": "XIST",
820+ "name": "Borsa İstanbul",
821+ "operator": "Borsa İstanbul",
822+ "country": "TR",
823+ "city": "Istanbul",
824+ "timezone": "Europe/Istanbul",
825+ "currency": "TRY",
826+ "website": "https://www.borsaistanbul.com",
827+ "lat": 41.1085,
828+ "lon": 29.0288,
829+ "asset_classes": [
830+ "EQUITY"
831+ ],
832+ "sessions": {
833+ "regular": [
834+ {
835+ "open": "10:00",
836+ "close": "18:00"
837+ }
838+ ]
839+ }
840+ },
841+ {
842+ "id": "xmos",
843+ "mic": "MISX",
844+ "name": "Moscow Exchange",
845+ "operator": "MOEX",
846+ "country": "RU",
847+ "city": "Moscow",
848+ "timezone": "Europe/Moscow",
849+ "currency": "RUB",
850+ "website": "https://www.moex.com",
851+ "lat": 55.7558,
852+ "lon": 37.6173,
853+ "asset_classes": [
854+ "EQUITY"
855+ ],
856+ "sessions": {
857+ "regular": [
858+ {
859+ "open": "10:00",
860+ "close": "18:50"
861+ }
862+ ]
863+ }
864+ },
865+ {
866+ "id": "xtae",
867+ "mic": "XTAE",
868+ "name": "Tel Aviv Stock Exchange",
869+ "operator": "TASE",
870+ "country": "IL",
871+ "city": "Tel Aviv",
872+ "timezone": "Asia/Jerusalem",
873+ "currency": "ILS",
874+ "website": "https://www.tase.co.il",
875+ "lat": 32.0636,
876+ "lon": 34.7708,
877+ "asset_classes": [
878+ "EQUITY"
879+ ],
880+ "sessions": {
881+ "regular": [
882+ {
883+ "open": "10:00",
884+ "close": "17:15"
885+ }
886+ ],
887+ "weekdays": [
888+ 0,
889+ 1,
890+ 2,
891+ 3,
892+ 4
893+ ]
894+ }
895+ },
896+ {
897+ "id": "xsau",
898+ "mic": "XSAU",
899+ "name": "Saudi Exchange (Tadawul)",
900+ "operator": "Saudi Tadawul Group",
901+ "country": "SA",
902+ "city": "Riyadh",
903+ "timezone": "Asia/Riyadh",
904+ "currency": "SAR",
905+ "website": "https://www.saudiexchange.sa",
906+ "lat": 24.7136,
907+ "lon": 46.6753,
908+ "asset_classes": [
909+ "EQUITY"
910+ ],
911+ "sessions": {
912+ "regular": [
913+ {
914+ "open": "10:00",
915+ "close": "15:00"
916+ }
917+ ],
918+ "weekdays": [
919+ 0,
920+ 1,
921+ 2,
922+ 3,
923+ 4
924+ ]
925+ }
926+ },
927+ {
928+ "id": "xdfm",
929+ "mic": "XDFM",
930+ "name": "Dubai Financial Market",
931+ "operator": "DFM",
932+ "country": "AE",
933+ "city": "Dubai",
934+ "timezone": "Asia/Dubai",
935+ "currency": "AED",
936+ "website": "https://www.dfm.ae",
937+ "lat": 25.2172,
938+ "lon": 55.279,
939+ "asset_classes": [
940+ "EQUITY"
941+ ],
942+ "sessions": {
943+ "regular": [
944+ {
945+ "open": "10:00",
946+ "close": "14:45"
947+ }
948+ ]
949+ }
950+ },
951+ {
952+ "id": "dsmd",
953+ "mic": "DSMD",
954+ "name": "Qatar Stock Exchange",
955+ "operator": "QSE",
956+ "country": "QA",
957+ "city": "Doha",
958+ "timezone": "Asia/Qatar",
959+ "currency": "QAR",
960+ "website": "https://www.qe.com.qa",
961+ "lat": 25.2854,
962+ "lon": 51.531,
963+ "asset_classes": [
964+ "EQUITY"
965+ ],
966+ "sessions": {
967+ "regular": [
968+ {
969+ "open": "09:30",
970+ "close": "13:15"
971+ }
972+ ],
973+ "weekdays": [
974+ 0,
975+ 1,
976+ 2,
977+ 3,
978+ 4
979+ ]
980+ }
981+ },
982+ {
983+ "id": "xcai",
984+ "mic": "XCAI",
985+ "name": "Egyptian Exchange",
986+ "operator": "EGX",
987+ "country": "EG",
988+ "city": "Cairo",
989+ "timezone": "Africa/Cairo",
990+ "currency": "EGP",
991+ "website": "https://www.egx.com.eg",
992+ "lat": 30.0444,
993+ "lon": 31.2357,
994+ "asset_classes": [
995+ "EQUITY"
996+ ],
997+ "sessions": {
998+ "regular": [
999+ {
1000+ "open": "10:00",
1001+ "close": "14:30"
1002+ }
1003+ ],
1004+ "weekdays": [
1005+ 0,
1006+ 1,
1007+ 2,
1008+ 3,
1009+ 4
1010+ ]
1011+ }
1012+ },
1013+ {
1014+ "id": "xjse",
1015+ "mic": "XJSE",
1016+ "name": "Johannesburg Stock Exchange",
1017+ "operator": "JSE Limited",
1018+ "country": "ZA",
1019+ "city": "Johannesburg",
1020+ "timezone": "Africa/Johannesburg",
1021+ "currency": "ZAR",
1022+ "website": "https://www.jse.co.za",
1023+ "lat": -26.1076,
1024+ "lon": 28.0567,
1025+ "asset_classes": [
1026+ "EQUITY"
1027+ ],
1028+ "sessions": {
1029+ "regular": [
1030+ {
1031+ "open": "09:00",
1032+ "close": "17:00"
1033+ }
1034+ ]
1035+ }
1036+ },
1037+ {
1038+ "id": "xnsa",
1039+ "mic": "XNSA",
1040+ "name": "Nigerian Exchange",
1041+ "operator": "NGX Group",
1042+ "country": "NG",
1043+ "city": "Lagos",
1044+ "timezone": "Africa/Lagos",
1045+ "currency": "NGN",
1046+ "website": "https://ngxgroup.com",
1047+ "lat": 6.4541,
1048+ "lon": 3.3947,
1049+ "asset_classes": [
1050+ "EQUITY"
1051+ ],
1052+ "sessions": {
1053+ "regular": [
1054+ {
1055+ "open": "09:30",
1056+ "close": "14:30"
1057+ }
1058+ ]
1059+ }
1060+ },
1061+ {
1062+ "id": "xnai",
1063+ "mic": "XNAI",
1064+ "name": "Nairobi Securities Exchange",
1065+ "operator": "NSE",
1066+ "country": "KE",
1067+ "city": "Nairobi",
1068+ "timezone": "Africa/Nairobi",
1069+ "currency": "KES",
1070+ "website": "https://www.nse.co.ke",
1071+ "lat": -1.2921,
1072+ "lon": 36.8219,
1073+ "asset_classes": [
1074+ "EQUITY"
1075+ ],
1076+ "sessions": {
1077+ "regular": [
1078+ {
1079+ "open": "09:30",
1080+ "close": "15:00"
1081+ }
1082+ ]
1083+ }
1084+ },
1085+ {
1086+ "id": "xnse",
1087+ "mic": "XNSE",
1088+ "name": "National Stock Exchange of India",
1089+ "operator": "NSE India",
1090+ "country": "IN",
1091+ "city": "Mumbai",
1092+ "timezone": "Asia/Kolkata",
1093+ "currency": "INR",
1094+ "website": "https://www.nseindia.com",
1095+ "lat": 19.0607,
1096+ "lon": 72.8611,
1097+ "asset_classes": [
1098+ "EQUITY"
1099+ ],
1100+ "sessions": {
1101+ "regular": [
1102+ {
1103+ "open": "09:15",
1104+ "close": "15:30"
1105+ }
1106+ ]
1107+ }
1108+ },
1109+ {
1110+ "id": "xbom",
1111+ "mic": "XBOM",
1112+ "name": "BSE (Bombay Stock Exchange)",
1113+ "operator": "BSE Ltd",
1114+ "country": "IN",
1115+ "city": "Mumbai",
1116+ "timezone": "Asia/Kolkata",
1117+ "currency": "INR",
1118+ "website": "https://www.bseindia.com",
1119+ "lat": 18.9299,
1120+ "lon": 72.8336,
1121+ "asset_classes": [
1122+ "EQUITY"
1123+ ],
1124+ "sessions": {
1125+ "regular": [
1126+ {
1127+ "open": "09:15",
1128+ "close": "15:30"
1129+ }
1130+ ]
1131+ }
1132+ },
1133+ {
1134+ "id": "xshg",
1135+ "mic": "XSHG",
1136+ "name": "Shanghai Stock Exchange",
1137+ "operator": "SSE",
1138+ "country": "CN",
1139+ "city": "Shanghai",
1140+ "timezone": "Asia/Shanghai",
1141+ "currency": "CNY",
1142+ "website": "https://www.sse.com.cn",
1143+ "lat": 31.2304,
1144+ "lon": 121.4737,
1145+ "asset_classes": [
1146+ "EQUITY"
1147+ ],
1148+ "sessions": {
1149+ "regular": [
1150+ {
1151+ "open": "09:30",
1152+ "close": "11:30"
1153+ },
1154+ {
1155+ "open": "13:00",
1156+ "close": "15:00"
1157+ }
1158+ ]
1159+ }
1160+ },
1161+ {
1162+ "id": "xshe",
1163+ "mic": "XSHE",
1164+ "name": "Shenzhen Stock Exchange",
1165+ "operator": "SZSE",
1166+ "country": "CN",
1167+ "city": "Shenzhen",
1168+ "timezone": "Asia/Shanghai",
1169+ "currency": "CNY",
1170+ "website": "https://www.szse.cn",
1171+ "lat": 22.5431,
1172+ "lon": 114.0579,
1173+ "asset_classes": [
1174+ "EQUITY"
1175+ ],
1176+ "sessions": {
1177+ "regular": [
1178+ {
1179+ "open": "09:30",
1180+ "close": "11:30"
1181+ },
1182+ {
1183+ "open": "13:00",
1184+ "close": "15:00"
1185+ }
1186+ ]
1187+ }
1188+ },
1189+ {
1190+ "id": "xhkg",
1191+ "mic": "XHKG",
1192+ "name": "Hong Kong Stock Exchange",
1193+ "operator": "HKEX",
1194+ "country": "HK",
1195+ "city": "Hong Kong",
1196+ "timezone": "Asia/Hong_Kong",
1197+ "currency": "HKD",
1198+ "website": "https://www.hkex.com.hk",
1199+ "lat": 22.283,
1200+ "lon": 114.1588,
1201+ "asset_classes": [
1202+ "EQUITY",
1203+ "ETF"
1204+ ],
1205+ "sessions": {
1206+ "regular": [
1207+ {
1208+ "open": "09:30",
1209+ "close": "12:00"
1210+ },
1211+ {
1212+ "open": "13:00",
1213+ "close": "16:00"
1214+ }
1215+ ]
1216+ }
1217+ },
1218+ {
1219+ "id": "xtai",
1220+ "mic": "XTAI",
1221+ "name": "Taiwan Stock Exchange",
1222+ "operator": "TWSE",
1223+ "country": "TW",
1224+ "city": "Taipei",
1225+ "timezone": "Asia/Taipei",
1226+ "currency": "TWD",
1227+ "website": "https://www.twse.com.tw",
1228+ "lat": 25.033,
1229+ "lon": 121.5654,
1230+ "asset_classes": [
1231+ "EQUITY"
1232+ ],
1233+ "sessions": {
1234+ "regular": [
1235+ {
1236+ "open": "09:00",
1237+ "close": "13:30"
1238+ }
1239+ ]
1240+ }
1241+ },
1242+ {
1243+ "id": "xkrx",
1244+ "mic": "XKRX",
1245+ "name": "Korea Exchange",
1246+ "operator": "KRX",
1247+ "country": "KR",
1248+ "city": "Seoul",
1249+ "timezone": "Asia/Seoul",
1250+ "currency": "KRW",
1251+ "website": "https://global.krx.co.kr",
1252+ "lat": 37.5236,
1253+ "lon": 126.926,
1254+ "asset_classes": [
1255+ "EQUITY"
1256+ ],
1257+ "sessions": {
1258+ "regular": [
1259+ {
1260+ "open": "09:00",
1261+ "close": "15:30"
1262+ }
1263+ ]
1264+ }
1265+ },
1266+ {
1267+ "id": "xtks",
1268+ "mic": "XTKS",
1269+ "name": "Tokyo Stock Exchange",
1270+ "operator": "Japan Exchange Group",
1271+ "country": "JP",
1272+ "city": "Tokyo",
1273+ "timezone": "Asia/Tokyo",
1274+ "currency": "JPY",
1275+ "website": "https://www.jpx.co.jp",
1276+ "lat": 35.6817,
1277+ "lon": 139.7785,
1278+ "asset_classes": [
1279+ "EQUITY",
1280+ "ETF"
1281+ ],
1282+ "sessions": {
1283+ "regular": [
1284+ {
1285+ "open": "09:00",
1286+ "close": "11:30"
1287+ },
1288+ {
1289+ "open": "12:30",
1290+ "close": "15:30"
1291+ }
1292+ ]
1293+ }
1294+ },
1295+ {
1296+ "id": "xses",
1297+ "mic": "XSES",
1298+ "name": "Singapore Exchange",
1299+ "operator": "SGX Group",
1300+ "country": "SG",
1301+ "city": "Singapore",
1302+ "timezone": "Asia/Singapore",
1303+ "currency": "SGD",
1304+ "website": "https://www.sgx.com",
1305+ "lat": 1.2792,
1306+ "lon": 103.8507,
1307+ "asset_classes": [
1308+ "EQUITY"
1309+ ],
1310+ "sessions": {
1311+ "regular": [
1312+ {
1313+ "open": "09:00",
1314+ "close": "12:00"
1315+ },
1316+ {
1317+ "open": "13:00",
1318+ "close": "17:00"
1319+ }
1320+ ]
1321+ }
1322+ },
1323+ {
1324+ "id": "xkls",
1325+ "mic": "XKLS",
1326+ "name": "Bursa Malaysia",
1327+ "operator": "Bursa Malaysia",
1328+ "country": "MY",
1329+ "city": "Kuala Lumpur",
1330+ "timezone": "Asia/Kuala_Lumpur",
1331+ "currency": "MYR",
1332+ "website": "https://www.bursamalaysia.com",
1333+ "lat": 3.149,
1334+ "lon": 101.6959,
1335+ "asset_classes": [
1336+ "EQUITY"
1337+ ],
1338+ "sessions": {
1339+ "regular": [
1340+ {
1341+ "open": "09:00",
1342+ "close": "12:30"
1343+ },
1344+ {
1345+ "open": "14:30",
1346+ "close": "17:00"
1347+ }
1348+ ]
1349+ }
1350+ },
1351+ {
1352+ "id": "xbkk",
1353+ "mic": "XBKK",
1354+ "name": "Stock Exchange of Thailand",
1355+ "operator": "SET",
1356+ "country": "TH",
1357+ "city": "Bangkok",
1358+ "timezone": "Asia/Bangkok",
1359+ "currency": "THB",
1360+ "website": "https://www.set.or.th",
1361+ "lat": 13.7563,
1362+ "lon": 100.5018,
1363+ "asset_classes": [
1364+ "EQUITY"
1365+ ],
1366+ "sessions": {
1367+ "regular": [
1368+ {
1369+ "open": "10:00",
1370+ "close": "12:30"
1371+ },
1372+ {
1373+ "open": "14:30",
1374+ "close": "16:30"
1375+ }
1376+ ]
1377+ }
1378+ },
1379+ {
1380+ "id": "xidx",
1381+ "mic": "XIDX",
1382+ "name": "Indonesia Stock Exchange",
1383+ "operator": "IDX",
1384+ "country": "ID",
1385+ "city": "Jakarta",
1386+ "timezone": "Asia/Jakarta",
1387+ "currency": "IDR",
1388+ "website": "https://www.idx.co.id",
1389+ "lat": -6.2245,
1390+ "lon": 106.809,
1391+ "asset_classes": [
1392+ "EQUITY"
1393+ ],
1394+ "sessions": {
1395+ "regular": [
1396+ {
1397+ "open": "09:00",
1398+ "close": "11:30"
1399+ },
1400+ {
1401+ "open": "13:30",
1402+ "close": "15:50"
1403+ }
1404+ ]
1405+ }
1406+ },
1407+ {
1408+ "id": "xphs",
1409+ "mic": "XPHS",
1410+ "name": "Philippine Stock Exchange",
1411+ "operator": "PSE",
1412+ "country": "PH",
1413+ "city": "Manila",
1414+ "timezone": "Asia/Manila",
1415+ "currency": "PHP",
1416+ "website": "https://www.pse.com.ph",
1417+ "lat": 14.5547,
1418+ "lon": 121.0244,
1419+ "asset_classes": [
1420+ "EQUITY"
1421+ ],
1422+ "sessions": {
1423+ "regular": [
1424+ {
1425+ "open": "09:30",
1426+ "close": "12:00"
1427+ },
1428+ {
1429+ "open": "13:00",
1430+ "close": "15:00"
1431+ }
1432+ ]
1433+ }
1434+ },
1435+ {
1436+ "id": "xstc",
1437+ "mic": "XSTC",
1438+ "name": "Ho Chi Minh Stock Exchange",
1439+ "operator": "HOSE",
1440+ "country": "VN",
1441+ "city": "Ho Chi Minh City",
1442+ "timezone": "Asia/Ho_Chi_Minh",
1443+ "currency": "VND",
1444+ "website": "https://www.hsx.vn",
1445+ "lat": 10.7769,
1446+ "lon": 106.7009,
1447+ "asset_classes": [
1448+ "EQUITY"
1449+ ],
1450+ "sessions": {
1451+ "regular": [
1452+ {
1453+ "open": "09:00",
1454+ "close": "11:30"
1455+ },
1456+ {
1457+ "open": "13:00",
1458+ "close": "15:00"
1459+ }
1460+ ]
1461+ }
1462+ },
1463+ {
1464+ "id": "xasx",
1465+ "mic": "XASX",
1466+ "name": "Australian Securities Exchange",
1467+ "operator": "ASX Ltd",
1468+ "country": "AU",
1469+ "city": "Sydney",
1470+ "timezone": "Australia/Sydney",
1471+ "currency": "AUD",
1472+ "website": "https://www.asx.com.au",
1473+ "lat": -33.8651,
1474+ "lon": 151.2099,
1475+ "asset_classes": [
1476+ "EQUITY",
1477+ "ETF"
1478+ ],
1479+ "sessions": {
1480+ "regular": [
1481+ {
1482+ "open": "10:00",
1483+ "close": "16:00"
1484+ }
1485+ ]
1486+ }
1487+ },
1488+ {
1489+ "id": "xnze",
1490+ "mic": "XNZE",
1491+ "name": "NZX",
1492+ "operator": "NZX Limited",
1493+ "country": "NZ",
1494+ "city": "Wellington",
1495+ "timezone": "Pacific/Auckland",
1496+ "currency": "NZD",
1497+ "website": "https://www.nzx.com",
1498+ "lat": -41.2865,
1499+ "lon": 174.7762,
1500+ "asset_classes": [
1501+ "EQUITY"
1502+ ],
1503+ "sessions": {
1504+ "regular": [
1505+ {
1506+ "open": "10:00",
1507+ "close": "16:45"
1508+ }
1509+ ]
1510+ }
1511+ },
1512+ {
1513+ "id": "coinbase",
1514+ "mic": null,
1515+ "name": "Coinbase Exchange",
1516+ "operator": "Coinbase Global",
1517+ "country": "US",
1518+ "city": "San Francisco",
1519+ "timezone": "UTC",
1520+ "currency": "USD",
1521+ "website": "https://exchange.coinbase.com",
1522+ "lat": 37.7749,
1523+ "lon": -122.4194,
1524+ "asset_classes": [
1525+ "CRYPTO"
1526+ ],
1527+ "sessions": {
1528+ "regular": [],
1529+ "continuous": true
1530+ }
1531+ },
1532+ {
1533+ "id": "kraken",
1534+ "mic": null,
1535+ "name": "Kraken",
1536+ "operator": "Payward, Inc.",
1537+ "country": "US",
1538+ "city": "San Francisco",
1539+ "timezone": "UTC",
1540+ "currency": "USD",
1541+ "website": "https://www.kraken.com",
1542+ "lat": 37.79,
1543+ "lon": -122.4,
1544+ "asset_classes": [
1545+ "CRYPTO"
1546+ ],
1547+ "sessions": {
1548+ "regular": [],
1549+ "continuous": true
1550+ }
1551+ },
1552+ {
1553+ "id": "binance",
1554+ "mic": null,
1555+ "name": "Binance",
1556+ "operator": "Binance",
1557+ "country": "XX",
1558+ "city": null,
1559+ "timezone": "UTC",
1560+ "currency": "USDT",
1561+ "website": "https://www.binance.com",
1562+ "lat": 43.7384,
1563+ "lon": 7.4246,
1564+ "asset_classes": [
1565+ "CRYPTO"
1566+ ],
1567+ "sessions": {
1568+ "regular": [],
1569+ "continuous": true
1570+ }
1571+ },
1572+ {
1573+ "id": "okx",
1574+ "mic": null,
1575+ "name": "OKX",
1576+ "operator": "OKX",
1577+ "country": "XX",
1578+ "city": null,
1579+ "timezone": "UTC",
1580+ "currency": "USDT",
1581+ "website": "https://www.okx.com",
1582+ "lat": 1.3521,
1583+ "lon": 103.8198,
1584+ "asset_classes": [
1585+ "CRYPTO"
1586+ ],
1587+ "sessions": {
1588+ "regular": [],
1589+ "continuous": true
1590+ }
1591+ },
1592+ {
1593+ "id": "bitstamp",
1594+ "mic": null,
1595+ "name": "Bitstamp",
1596+ "operator": "Bitstamp Ltd",
1597+ "country": "GB",
1598+ "city": "London",
1599+ "timezone": "UTC",
1600+ "currency": "USD",
1601+ "website": null,
1602+ "lat": 51.5074,
1603+ "lon": -0.1278,
1604+ "asset_classes": [
1605+ "CRYPTO"
1606+ ],
1607+ "sessions": {
1608+ "regular": [],
1609+ "continuous": true
1610+ }
1611+ },
1612+ {
1613+ "id": "gemini",
1614+ "mic": null,
1615+ "name": "Gemini",
1616+ "operator": "Gemini Trust Company, LLC",
1617+ "country": "US",
1618+ "city": "New York",
1619+ "timezone": "UTC",
1620+ "currency": "USD",
1621+ "website": null,
1622+ "lat": 40.7484,
1623+ "lon": -73.9857,
1624+ "asset_classes": [
1625+ "CRYPTO"
1626+ ],
1627+ "sessions": {
1628+ "regular": [],
1629+ "continuous": true
1630+ }
1631+ },
1632+ {
1633+ "id": "bitfinex",
1634+ "mic": null,
1635+ "name": "Bitfinex",
1636+ "operator": "iFinex Inc.",
1637+ "country": "XX",
1638+ "city": null,
1639+ "timezone": "UTC",
1640+ "currency": "USD",
1641+ "website": null,
1642+ "lat": 22.3193,
1643+ "lon": 114.1694,
1644+ "asset_classes": [
1645+ "CRYPTO"
1646+ ],
1647+ "sessions": {
1648+ "regular": [],
1649+ "continuous": true
1650+ }
1651+ },
1652+ {
1653+ "id": "bybit",
1654+ "mic": null,
1655+ "name": "Bybit",
1656+ "operator": "Bybit",
1657+ "country": "XX",
1658+ "city": "Dubai",
1659+ "timezone": "UTC",
1660+ "currency": "USD",
1661+ "website": null,
1662+ "lat": 25.2048,
1663+ "lon": 55.2708,
1664+ "asset_classes": [
1665+ "CRYPTO"
1666+ ],
1667+ "sessions": {
1668+ "regular": [],
1669+ "continuous": true
1670+ }
1671+ },
1672+ {
1673+ "id": "gate",
1674+ "mic": null,
1675+ "name": "Gate",
1676+ "operator": "Gate Technology Inc.",
1677+ "country": "XX",
1678+ "city": null,
1679+ "timezone": "UTC",
1680+ "currency": "USD",
1681+ "website": null,
1682+ "lat": 35.1796,
1683+ "lon": 129.0756,
1684+ "asset_classes": [
1685+ "CRYPTO"
1686+ ],
1687+ "sessions": {
1688+ "regular": [],
1689+ "continuous": true
1690+ }
1691+ },
1692+ {
1693+ "id": "cryptocom",
1694+ "mic": null,
1695+ "name": "Crypto.com Exchange",
1696+ "operator": "Crypto.com",
1697+ "country": "SG",
1698+ "city": "Singapore",
1699+ "timezone": "UTC",
1700+ "currency": "USD",
1701+ "website": null,
1702+ "lat": 1.29,
1703+ "lon": 103.85,
1704+ "asset_classes": [
1705+ "CRYPTO"
1706+ ],
1707+ "sessions": {
1708+ "regular": [],
1709+ "continuous": true
1710+ }
1711+ },
1712+ {
1713+ "id": "kucoin",
1714+ "mic": null,
1715+ "name": "KuCoin",
1716+ "operator": "KuCoin",
1717+ "country": "SC",
1718+ "city": "Victoria",
1719+ "timezone": "UTC",
1720+ "currency": "USD",
1721+ "website": null,
1722+ "lat": -4.6191,
1723+ "lon": 55.4513,
1724+ "asset_classes": [
1725+ "CRYPTO"
1726+ ],
1727+ "sessions": {
1728+ "regular": [],
1729+ "continuous": true
1730+ }
1731+ }
1732+]
\ No newline at end of file
modified connectors/src/_shared/crypto.ts +1 −1
@@ -86,7 +86,7 @@ export function tickerObservations(
86 const n = num(v); 86 const n = num(v);
87 if (n == null) return; 87 if (n == null) return;
88 if (field !== "VOLUME" && field !== "BID_SIZE" && field !== "ASK_SIZE" && n <= 0) return; 88 if (field !== "VOLUME" && field !== "BID_SIZE" && field !== "ASK_SIZE" && n <= 0) return;
89− out.push({ symbol, instrumentHint: hint, field, value: n, currency: quote.toUpperCase(), sourceTimestamp: meta.sourceTimestamp, timestampTrust: meta.timestampTrust, sequence: meta.sequence ?? null, rightsStatus: meta.rightsStatus, realtimeStatus: meta.realtimeStatus }); 89+ out.push({ symbol, instrumentHint: hint, field, value: n, currency: quote.toUpperCase(), observationType: field === "BID" || field === "ASK" || field === "BID_SIZE" || field === "ASK_SIZE" ? "QUOTE" : "TRADE", sourceTimestamp: meta.sourceTimestamp, timestampTrust: meta.timestampTrust, sequence: meta.sequence ?? null, rightsStatus: meta.rightsStatus, realtimeStatus: meta.realtimeStatus });
90 }; 90 };
91 push("LAST_PRICE", f.last); 91 push("LAST_PRICE", f.last);
92 push("OPEN", f.open); 92 push("OPEN", f.open);
added connectors/src/_shared/pairs.ts +102 −0
@@ -0,0 +1,102 @@
1+import type { InstrumentHint, NormalizedObservation, ObservationType, RealtimeStatus, RightsStatus, TimestampTrust } from "@market-atlas/market-model";
2+import { cryptoHint, type TickerFields } from "./crypto.js";
3+import { conventionalPair, fxHint } from "../ecb-frankfurter/index.js";
4+
5+export const FIAT = new Set(["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "NZD", "SGD", "HKD", "BRL", "MXN", "TRY", "ZAR", "ARS", "AED", "PLN", "SEK", "NOK", "DKK", "CZK", "HUF", "RON", "UAH", "INR", "KRW", "CNY"]);
6+export const STABLES = new Set(["USDT", "USDC", "USD1", "FDUSD", "TUSD", "DAI"]);
7+
8+export interface PairPlan {
9+ /** Instrument hint to resolve/create. */
10+ hint: InstrumentHint;
11+ /** Nature of the LAST_PRICE value. */
12+ type: ObservationType;
13+ /** Multiply venue prices by -1 exponent: when true, canonical value = 1 / venue price. */
14+ invert: boolean;
15+ currency: string;
16+}
17+
18+/**
19+ * Decide what a venue pair *means* for Market Atlas:
20+ * - fiat/fiat (Kraken EUR/USD, Bitstamp EURUSD) → FOREX instrument, TRADE (a real fiat market)
21+ * - stable/fiat or fiat/stable (USDT-EUR, EURUSDT) → FOREX instrument, STABLECOIN_PROXY (USDT ≈ USD)
22+ * - anything else → CRYPTO instrument, TRADE
23+ */
24+export function planPair(base: string, quote: string, exchangeId: string): PairPlan {
25+ const b = base.toUpperCase();
26+ const q = quote.toUpperCase();
27+ if (FIAT.has(b) && FIAT.has(q)) {
28+ const [cb, cq] = conventionalPair(b, q);
29+ return { hint: fxHint(cb, cq), type: "TRADE", invert: cb !== b, currency: cq };
30+ }
31+ const bStable = STABLES.has(b);
32+ const qStable = STABLES.has(q);
33+ if ((bStable && FIAT.has(q) && q !== "USD") || (qStable && FIAT.has(b) && b !== "USD")) {
34+ const fb = bStable ? "USD" : b;
35+ const fq = qStable ? "USD" : q;
36+ const [cb, cq] = conventionalPair(fb, fq);
37+ return { hint: fxHint(cb, cq), type: "STABLECOIN_PROXY", invert: cb !== fb, currency: cq };
38+ }
39+ return { hint: cryptoHint(b, q, exchangeId), type: "TRADE", invert: false, currency: q };
40+}
41+
42+const num = (v: unknown): number | null => {
43+ if (v == null || v === "") return null;
44+ const n = typeof v === "number" ? v : Number(v);
45+ return Number.isFinite(n) ? n : null;
46+};
47+
48+/**
49+ * Venue ticker → observations for whatever the pair means (crypto, live FX, stablecoin proxy).
50+ * Inverted pairs swap bid/ask and drop venue-specific volumes (they would be in the wrong unit).
51+ */
52+export function pairObservations(
53+ symbol: string,
54+ base: string,
55+ quote: string,
56+ exchangeId: string,
57+ f: TickerFields,
58+ meta: { sourceTimestamp: number | null; timestampTrust: TimestampTrust; sequence?: number | string | null; rightsStatus: RightsStatus; realtimeStatus: RealtimeStatus },
59+): NormalizedObservation[] {
60+ const plan = planPair(base, quote, exchangeId);
61+ const out: NormalizedObservation[] = [];
62+ const conv = (v: number) => (plan.invert ? 1 / v : v);
63+ const push = (field: NormalizedObservation["field"], v: unknown, type: ObservationType) => {
64+ const n = num(v);
65+ if (n == null) return;
66+ if (field !== "VOLUME" && field !== "BID_SIZE" && field !== "ASK_SIZE" && n <= 0) return;
67+ const value = field === "VOLUME" || field === "BID_SIZE" || field === "ASK_SIZE" ? n : Number(conv(n).toPrecision(10));
68+ out.push({ symbol, instrumentHint: plan.hint, field, value, currency: plan.currency, observationType: type, sourceTimestamp: meta.sourceTimestamp, timestampTrust: meta.timestampTrust, sequence: meta.sequence ?? null, rightsStatus: meta.rightsStatus, realtimeStatus: meta.realtimeStatus, meta: plan.type === "STABLECOIN_PROXY" ? { venue_pair: `${base}/${quote}`, proxy: true } : undefined });
69+ };
70+ const quoteType: ObservationType = plan.type === "STABLECOIN_PROXY" ? "STABLECOIN_PROXY" : "QUOTE";
71+ push("LAST_PRICE", f.last, plan.type);
72+ if (plan.invert) {
73+ // 1/x flips the order: venue ask becomes our bid.
74+ push("BID", f.ask, quoteType);
75+ push("ASK", f.bid, quoteType);
76+ push("HIGH", f.low, plan.type);
77+ push("LOW", f.high, plan.type);
78+ push("OPEN", f.open, plan.type);
79+ } else {
80+ push("BID", f.bid, quoteType);
81+ push("ASK", f.ask, quoteType);
82+ push("HIGH", f.high, plan.type);
83+ push("LOW", f.low, plan.type);
84+ push("OPEN", f.open, plan.type);
85+ push("BID_SIZE", f.bidSize, quoteType);
86+ push("ASK_SIZE", f.askSize, quoteType);
87+ if (plan.type !== "STABLECOIN_PROXY") push("VOLUME", f.volume, plan.type);
88+ if (plan.type === "TRADE" && plan.hint.assetClass === "CRYPTO") push("VWAP", f.vwap, plan.type);
89+ }
90+ return out;
91+}
92+
93+/** Split a venue symbol using an explicit separator or a list of known quote currencies. */
94+export function splitVenueSymbol(symbol: string, sep: string | null, quotes: string[] = ["USDT", "USDC", "USD", "EUR", "GBP", "JPY", "BTC", "ETH", "TRY", "BRL", "MXN", "ZAR", "ARS", "AUD", "SGD", "AED", "CAD", "CHF"]): [string, string] | null {
95+ if (sep) {
96+ const [b, q] = symbol.split(sep);
97+ return b && q ? [b.toUpperCase(), q.toUpperCase()] : null;
98+ }
99+ const s = symbol.toUpperCase();
100+ for (const q of quotes.sort((a, b) => b.length - a.length)) if (s.endsWith(q) && s.length > q.length) return [s.slice(0, -q.length), q];
101+ return null;
102+}
modified connectors/src/bank-of-canada/index.ts +3 −3
@@ -81,11 +81,11 @@ export const bankOfCanada = defineConnector({
81 const foreign = sid.slice(2, 5); 81 const foreign = sid.slice(2, 5);
82 const [b, q] = conventionalPair(foreign, "CAD"); 82 const [b, q] = conventionalPair(foreign, "CAD");
83 const v = b === foreign ? value : 1 / value; // series is CAD per 1 unit of foreign 83 const v = b === foreign ? value : 1 / value; // series is CAD per 1 unit of foreign
84− observations.push({ symbol: `${b}${q}`, instrumentHint: fxHint(b, q), field: field === "RATE" || field === "YIELD" ? "LAST_PRICE" : field, value: Number(v.toPrecision(8)), currency: q, sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date } }); 84+ observations.push({ symbol: `${b}${q}`, instrumentHint: fxHint(b, q), field: field === "RATE" || field === "YIELD" ? "LAST_PRICE" : field, value: Number(v.toPrecision(8)), currency: q, observationType: "OFFICIAL_FIX", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date } });
85 } else if (r.kind === "rates" && RATE_SERIES[sid]) { 85 } else if (r.kind === "rates" && RATE_SERIES[sid]) {
86 const s = RATE_SERIES[sid]!; 86 const s = RATE_SERIES[sid]!;
87− observations.push({ symbol: s.symbol, instrumentHint: rateHint(s), field: field === "PREVIOUS_CLOSE" ? "PREVIOUS_CLOSE" : s.field, value, currency: "CAD", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date, label: p.seriesDetail?.[sid]?.label } }); 87+ observations.push({ symbol: s.symbol, instrumentHint: rateHint(s), field: field === "PREVIOUS_CLOSE" ? "PREVIOUS_CLOSE" : s.field, value, currency: "CAD", observationType: "REFERENCE_RATE", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date, label: p.seriesDetail?.[sid]?.label } });
88− if (field !== "PREVIOUS_CLOSE") observations.push({ symbol: s.symbol, instrumentHint: rateHint(s), field: "LAST_PRICE", value, currency: "CAD", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date } }); 88+ if (field !== "PREVIOUS_CLOSE") observations.push({ symbol: s.symbol, instrumentHint: rateHint(s), field: "LAST_PRICE", value, currency: "CAD", observationType: "REFERENCE_RATE", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date } });
89 } 89 }
90 }; 90 };
91 emit(last, "LAST_PRICE"); 91 emit(last, "LAST_PRICE");
modified connectors/src/binance-ws/index.ts +9 −5
@@ -1,8 +1,12 @@
1 import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; 1 import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";
2 import { parseTimestamp } from "@market-atlas/market-model"; 2 import { parseTimestamp } from "@market-atlas/market-model";
3−import { MAJOR_BASES, cryptoSeeds, tickerObservations } from "../_shared/crypto.js"; 3+import { MAJOR_BASES, cryptoSeeds } from "../_shared/crypto.js";
4+import { pairObservations } from "../_shared/pairs.js";
4 5
5−const PAIRS: Array<[string, string]> = MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USDT"] as [string, string]).concat([["BNB", "USDT"], ["TRX", "USDT"], ["PAXG", "USDT"]]); 6+const CRYPTO_PAIRS: Array<[string, string]> = MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USDT"] as [string, string]).concat([["BNB", "USDT"], ["TRX", "USDT"], ["PAXG", "USDT"]]);
7+/** Fiat/stablecoin markets → live FX proxies (USDT ≈ USD), labelled STABLECOIN_PROXY. */
8+const FX_PROXY_PAIRS: Array<[string, string]> = [["EUR", "USDT"], ["USDT", "BRL"], ["USDT", "MXN"], ["USDT", "TRY"], ["USDT", "ZAR"], ["USDT", "ARS"]];
9+const PAIRS = [...CRYPTO_PAIRS, ...FX_PROXY_PAIRS];
6 const SYMBOLS = PAIRS.map(([b, q]) => `${b}${q}`); 10 const SYMBOLS = PAIRS.map(([b, q]) => `${b}${q}`);
7 const streamUrl = (symbols: string[]) => `wss://stream.binance.com:9443/stream?streams=${symbols.map((s) => `${s.toLowerCase()}@miniTicker`).join("/")}`; 11 const streamUrl = (symbols: string[]) => `wss://stream.binance.com:9443/stream?streams=${symbols.map((s) => `${s.toLowerCase()}@miniTicker`).join("/")}`;
8 12
@@ -21,7 +25,7 @@ export const binanceWs = defineConnector({
21 expectedLatencyMs: 1000, 25 expectedLatencyMs: 1000,
22 supportsStreaming: true, 26 supportsStreaming: true,
23 supportsHistorical: false, 27 supportsHistorical: false,
24− assetClasses: ["CRYPTO"], 28+ assetClasses: ["CRYPTO", "FOREX"],
25 exchanges: ["binance"], 29 exchanges: ["binance"],
26 homepage: "https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams", 30 homepage: "https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams",
27 description: "Public Binance spot combined WebSocket stream (miniTicker per symbol, pushed every second) for USDT-quoted majors. Exchange event time (E) is used as source timestamp.", 31 description: "Public Binance spot combined WebSocket stream (miniTicker per symbol, pushed every second) for USDT-quoted majors. Exchange event time (E) is used as source timestamp.",
@@ -30,7 +34,7 @@ export const binanceWs = defineConnector({
30 sourceFamily: "binance", 34 sourceFamily: "binance",
31 enabled: true, 35 enabled: true,
32 }, 36 },
33− seeds: cryptoSeeds(PAIRS, "binance", (b, q) => `${b}${q}`), 37+ seeds: cryptoSeeds(CRYPTO_PAIRS, "binance", (b, q) => `${b}${q}`),
34 defaultSymbols: SYMBOLS, 38 defaultSymbols: SYMBOLS,
35 async start(ctx) { 39 async start(ctx) {
36 const ws = ctx.openWebSocket(streamUrl(ctx.watchedSymbols()), { 40 const ws = ctx.openWebSocket(streamUrl(ctx.watchedSymbols()), {
@@ -57,7 +61,7 @@ export const binanceWs = defineConnector({
57 const quote = pair?.[1] ?? (m.s.endsWith("USDT") ? "USDT" : null); 61 const quote = pair?.[1] ?? (m.s.endsWith("USDT") ? "USDT" : null);
58 if (!base || !quote) return { observations: [] }; 62 if (!base || !quote) return { observations: [] };
59 return { 63 return {
60− observations: tickerObservations(m.s, base, quote, "binance", { last: m.c, open: m.o, high: m.h, low: m.l, volume: m.v }, { 64+ observations: pairObservations(m.s, base, quote, "binance", { last: m.c, open: m.o, high: m.h, low: m.l, volume: m.v }, {
61 sourceTimestamp: parseTimestamp(m.E), 65 sourceTimestamp: parseTimestamp(m.E),
62 timestampTrust: "EXCHANGE", 66 timestampTrust: "EXCHANGE",
63 rightsStatus: "PUBLIC_ATTRIBUTED", 67 rightsStatus: "PUBLIC_ATTRIBUTED",
added connectors/src/bitfinex-ws/README.md +7 −0
@@ -0,0 +1,7 @@
1+# bitfinex-ws
2+
3+Bitfinex public WebSocket v2 (`wss://api-pub.bitfinex.com/ws/2`), `ticker` channel.
4+
5+- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (no per-frame timestamp → CONNECTOR trust)
6+- **Frames**: `[chanId, [BID, BID_SIZE, ASK, ASK_SIZE, DAILY_CHANGE, DAILY_CHANGE_REL, LAST, VOLUME, HIGH, LOW]]`; `hb` heartbeats ignored. The `subscribed` event maps `chanId → symbol` (kept per connection).
7+- **Pairs**: USD & USDT (`UST`) crypto majors, BTC/EUR, EUR/USDT (STABLECOIN_PROXY; Bitfinex has no fiat/fiat spot markets). Symbols longer than 3 letters use the `t<BASE>:<QUOTE>` form (e.g. `tDOGE:USD`).
added connectors/src/bitfinex-ws/fixtures/ticker.json +1 −0
@@ -0,0 +1 @@
1+{"symbol":"tBTCUSD","ticker":[77123,3.44004551,77142,1.58754094,-106,-0.00137263,77118,1639.7559031,77442,76979]}
added connectors/src/bitfinex-ws/index.ts +94 −0
@@ -0,0 +1,94 @@
1+import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";
2+import { pairObservations, planPair } from "../_shared/pairs.js";
3+
4+const WS_URL = "wss://api-pub.bitfinex.com/ws/2";
5+/** Bitfinex symbol → [base, quote] (UST = USDT on Bitfinex). */
6+const PAIRS: Record<string, [string, string]> = {
7+ tBTCUSD: ["BTC", "USD"],
8+ tETHUSD: ["ETH", "USD"],
9+ tSOLUSD: ["SOL", "USD"],
10+ tXRPUSD: ["XRP", "USD"],
11+ tLTCUSD: ["LTC", "USD"],
12+ tADAUSD: ["ADA", "USD"],
13+ "tDOGE:USD": ["DOGE", "USD"],
14+ tDOTUSD: ["DOT", "USD"],
15+ "tAVAX:USD": ["AVAX", "USD"],
16+ "tLINK:USD": ["LINK", "USD"],
17+ tBTCUST: ["BTC", "USDT"],
18+ tETHUST: ["ETH", "USDT"],
19+ tBTCEUR: ["BTC", "EUR"],
20+ tEURUST: ["EUR", "USDT"],
21+};
22+
23+/** Bitfinex public WebSocket v2, `ticker` channel (array frames; channel id → symbol mapping kept per connection). */
24+export const bitfinexWs = defineConnector({
25+ metadata: {
26+ id: "bitfinex-ws",
27+ name: "Bitfinex — ticker channel (WS v2)",
28+ version: "1.0.0",
29+ sourceId: "bitfinex",
30+ organization: "iFinex Inc.",
31+ sourceType: "WEBSOCKET",
32+ jurisdiction: null,
33+ rightsStatus: "PUBLIC_ATTRIBUTED",
34+ realtimeStatus: "REALTIME",
35+ expectedLatencyMs: 500,
36+ supportsStreaming: true,
37+ supportsHistorical: false,
38+ assetClasses: ["CRYPTO", "FOREX"],
39+ exchanges: ["bitfinex"],
40+ homepage: "https://docs.bitfinex.com/reference/ws-public-ticker",
41+ description: "Public Bitfinex WebSocket v2 ticker channel (bid/ask with sizes, last price, 24h volume/high/low) for major USD and USDT crypto pairs, plus the EUR/USDT stablecoin market (live FX proxy).",
42+ rightsNotes: "Public market data displayed with attribution to Bitfinex.",
43+ termsUrl: "https://www.bitfinex.com/legal/general/terms",
44+ sourceFamily: "bitfinex",
45+ enabled: true,
46+ },
47+ seeds: Object.entries(PAIRS).map(([sym, [b, q]]) => ({ symbol: sym, hint: planPair(b, q, "bitfinex").hint, aliases: [`${b}-${q}`, `${b}/${q}`] })),
48+ defaultSymbols: Object.keys(PAIRS),
49+ async start(ctx) {
50+ const channels = new Map<number, string>();
51+ const ws = ctx.openWebSocket(WS_URL, {
52+ label: "bitfinex",
53+ staleAfterMs: 90_000,
54+ heartbeat: { intervalMs: 25_000, message: JSON.stringify({ event: "ping", cid: 1 }) },
55+ onOpen: (sock) => {
56+ channels.clear();
57+ for (const s of ctx.watchedSymbols()) sock.send({ event: "subscribe", channel: "ticker", symbol: s });
58+ },
59+ onMessage: (data) => {
60+ let msg: any;
61+ try {
62+ msg = JSON.parse(data);
63+ } catch {
64+ return;
65+ }
66+ if (Array.isArray(msg)) {
67+ const [chanId, body] = msg;
68+ if (body === "hb" || !Array.isArray(body)) return;
69+ const symbol = channels.get(chanId);
70+ if (symbol) ctx.emit(raw("bitfinex-ws", "bitfinex", "ticker", { symbol, ticker: body }));
71+ } else if (msg?.event === "subscribed" && msg.channel === "ticker") channels.set(msg.chanId, msg.symbol);
72+ else if (msg?.event === "error") ctx.reportError(new Error(String(msg.msg ?? "bitfinex error")), { code: msg.code, symbol: msg.symbol });
73+ },
74+ });
75+ ws.connect();
76+ },
77+ normalize(r): NormalizedBatch {
78+ const m = r.payload as { symbol?: string; ticker?: unknown[] };
79+ if (r.kind !== "ticker" || typeof m.symbol !== "string" || !Array.isArray(m.ticker) || m.ticker.length < 10) return { observations: [] };
80+ const pair = PAIRS[m.symbol];
81+ if (!pair) return { observations: [] };
82+ // [BID, BID_SIZE, ASK, ASK_SIZE, DAILY_CHANGE, DAILY_CHANGE_RELATIVE, LAST_PRICE, VOLUME, HIGH, LOW]
83+ const [bid, bidSize, ask, askSize, , , last, volume, high, low] = m.ticker;
84+ return {
85+ observations: pairObservations(m.symbol, pair[0], pair[1], "bitfinex", { bid, bidSize, ask, askSize, last, volume, high, low }, {
86+ sourceTimestamp: null,
87+ timestampTrust: "CONNECTOR",
88+ rightsStatus: "PUBLIC_ATTRIBUTED",
89+ realtimeStatus: "REALTIME",
90+ }),
91+ };
92+ },
93+ fixturesDir: "fixtures",
94+});
added connectors/src/bitstamp-ws/README.md +8 −0
@@ -0,0 +1,8 @@
1+# bitstamp-ws
2+
3+Bitstamp public WebSocket v2 (`wss://ws.bitstamp.net`): `live_trades_<pair>` + `order_book_<pair>`.
4+
5+- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (exchange `microtimestamp`)
6+- **Pairs**: BTC/ETH/XRP/LTC/SOL/ADA/LINK/DOGE in USD, BTC/ETH in EUR, BTC/USDT, plus **EUR/USD and GBP/USD** (real fiat spot markets → FOREX instruments, observation type TRADE).
7+- **Fields**: LAST_PRICE from trades, BID/ASK from the top of the book. No 24 h stats on these channels.
8+- **Heartbeat**: `{"event":"bts:heartbeat"}` every 25 s; the server may send `bts:request_reconnect` (handled by the SDK reconnect on close).
added connectors/src/bitstamp-ws/fixtures/book.json +1 −0
@@ -0,0 +1 @@
1+{"data":{"timestamp":"1789273907","microtimestamp":"1789273907518784","bids":[["1.17252","1200.00"],["1.17250","5000.00"]],"asks":[["1.17261","800.00"],["1.17265","2500.00"]]},"channel":"order_book_eurusd","event":"data"}
added connectors/src/bitstamp-ws/fixtures/trade.json +1 −0
@@ -0,0 +1 @@
1+{"data":{"id":634456427,"timestamp":"1789273894","amount":0.000154,"amount_str":"0.00015400","price":77203.59,"price_str":"77203.59","type":1,"microtimestamp":"1789273894413000","buy_order_id":2049797299200000,"sell_order_id":2049797815320577},"channel":"live_trades_btcusd","event":"trade"}
added connectors/src/bitstamp-ws/index.ts +96 −0
@@ -0,0 +1,96 @@
1+import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";
2+import type { NormalizedObservation } from "@market-atlas/market-model";
3+import { pairObservations, planPair } from "../_shared/pairs.js";
4+
5+const WS_URL = "wss://ws.bitstamp.net";
6+/** Bitstamp url_symbol → [base, quote]. Includes the two real fiat markets (EUR/USD, GBP/USD). */
7+const PAIRS: Record<string, [string, string]> = {
8+ btcusd: ["BTC", "USD"],
9+ ethusd: ["ETH", "USD"],
10+ xrpusd: ["XRP", "USD"],
11+ ltcusd: ["LTC", "USD"],
12+ solusd: ["SOL", "USD"],
13+ adausd: ["ADA", "USD"],
14+ linkusd: ["LINK", "USD"],
15+ dogeusd: ["DOGE", "USD"],
16+ btceur: ["BTC", "EUR"],
17+ etheur: ["ETH", "EUR"],
18+ btcusdt: ["BTC", "USDT"],
19+ eurusd: ["EUR", "USD"],
20+ gbpusd: ["GBP", "USD"],
21+};
22+
23+/** Bitstamp public WebSocket v2: live trades (price) + top of the order book (bid/ask) per pair. */
24+export const bitstampWs = defineConnector({
25+ metadata: {
26+ id: "bitstamp-ws",
27+ name: "Bitstamp — live trades & order book",
28+ version: "1.0.0",
29+ sourceId: "bitstamp",
30+ organization: "Bitstamp Ltd",
31+ sourceType: "WEBSOCKET",
32+ jurisdiction: "GB",
33+ rightsStatus: "PUBLIC_ATTRIBUTED",
34+ realtimeStatus: "REALTIME",
35+ expectedLatencyMs: 300,
36+ supportsStreaming: true,
37+ supportsHistorical: false,
38+ assetClasses: ["CRYPTO", "FOREX"],
39+ exchanges: ["bitstamp"],
40+ homepage: "https://www.bitstamp.net/websocket/v2/",
41+ description: "Public Bitstamp WebSocket v2: `live_trades_<pair>` (last trade, exchange microtimestamp) and `order_book_<pair>` (best bid/ask) for the major USD/EUR crypto markets and Bitstamp's fiat markets EUR/USD and GBP/USD.",
42+ rightsNotes: "Public market data displayed with attribution to Bitstamp.",
43+ termsUrl: "https://www.bitstamp.net/terms-of-use/",
44+ sourceFamily: "bitstamp",
45+ enabled: true,
46+ },
47+ seeds: Object.entries(PAIRS).map(([sym, [b, q]]) => ({ symbol: sym, hint: planPair(b, q, "bitstamp").hint, aliases: [`${b}-${q}`, `${b}/${q}`] })),
48+ defaultSymbols: Object.keys(PAIRS),
49+ async start(ctx) {
50+ const ws = ctx.openWebSocket(WS_URL, {
51+ label: "bitstamp",
52+ staleAfterMs: 120_000,
53+ heartbeat: { intervalMs: 25_000, message: JSON.stringify({ event: "bts:heartbeat" }) },
54+ onOpen: (sock) => {
55+ for (const p of ctx.watchedSymbols()) {
56+ sock.send({ event: "bts:subscribe", data: { channel: `live_trades_${p}` } });
57+ sock.send({ event: "bts:subscribe", data: { channel: `order_book_${p}` } });
58+ }
59+ },
60+ onMessage: (data) => {
61+ let msg: any;
62+ try {
63+ msg = JSON.parse(data);
64+ } catch {
65+ return;
66+ }
67+ if (msg?.event === "trade") ctx.emit(raw("bitstamp-ws", "bitstamp", "trade", msg));
68+ else if (msg?.event === "data" && typeof msg.channel === "string" && msg.channel.startsWith("order_book_")) ctx.emit(raw("bitstamp-ws", "bitstamp", "book", msg));
69+ else if (msg?.event === "bts:request_reconnect") ctx.logger.info("bitstamp asked to reconnect");
70+ },
71+ });
72+ ws.connect();
73+ },
74+ normalize(r): NormalizedBatch {
75+ const m = r.payload as { channel?: string; data?: Record<string, unknown> };
76+ const pairKey = typeof m.channel === "string" ? m.channel.replace(/^(live_trades_|order_book_)/, "") : "";
77+ const pair = PAIRS[pairKey];
78+ if (!pair || !m.data) return { observations: [] };
79+ const [base, quote] = pair;
80+ const micro = m.data.microtimestamp;
81+ const ts = typeof micro === "string" && /^\d+$/.test(micro) ? Math.floor(Number(micro) / 1000) : null;
82+ const meta = { sourceTimestamp: ts, timestampTrust: "EXCHANGE" as const, rightsStatus: "PUBLIC_ATTRIBUTED" as const, realtimeStatus: "REALTIME" as const };
83+ let observations: NormalizedObservation[] = [];
84+ if (r.kind === "trade") {
85+ observations = pairObservations(pairKey, base, quote, "bitstamp", { last: m.data.price }, { ...meta, sequence: typeof m.data.id === "number" ? m.data.id : null });
86+ } else if (r.kind === "book") {
87+ const bids = m.data.bids as unknown;
88+ const asks = m.data.asks as unknown;
89+ const bid = Array.isArray(bids) && Array.isArray(bids[0]) ? bids[0][0] : undefined;
90+ const ask = Array.isArray(asks) && Array.isArray(asks[0]) ? asks[0][0] : undefined;
91+ observations = pairObservations(pairKey, base, quote, "bitstamp", { bid, ask }, meta);
92+ }
93+ return { observations };
94+ },
95+ fixturesDir: "fixtures",
96+});
added connectors/src/bybit-ws/README.md +8 −0
@@ -0,0 +1,8 @@
1+# bybit-ws
2+
3+Bybit v5 public spot stream (`wss://stream.bybit.com/v5/public/spot`), topics `tickers.<SYMBOL>`.
4+
5+- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (frame `ts`)
6+- **Fields**: LAST_PRICE, HIGH/LOW (24 h), OPEN (= `prevPrice24h`), VOLUME (24 h). Bid/ask are not in this topic.
7+- **Heartbeat**: client `{"op":"ping"}` every 20 s.
8+- **Pairs**: USDT majors + EURUSDT / USDTBRL / USDTTRY (FX proxies).
added connectors/src/bybit-ws/fixtures/ticker.json +1 −0
@@ -0,0 +1 @@
1+{"topic":"tickers.BTCUSDT","ts":1789273910127,"type":"snapshot","cs":113941251526,"data":{"symbol":"BTCUSDT","lastPrice":"77229.4","highPrice24h":"77511.8","lowPrice24h":"77053.1","prevPrice24h":"77279.6","volume24h":"1891.389084","turnover24h":"146169585.1767","price24hPcnt":"-0.0006","usdIndexPrice":"77235.12"}}
added connectors/src/bybit-ws/index.ts +79 −0
@@ -0,0 +1,79 @@
1+import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";
2+import { parseTimestamp } from "@market-atlas/market-model";
3+import { MAJOR_BASES } from "../_shared/crypto.js";
4+import { pairObservations, planPair, splitVenueSymbol } from "../_shared/pairs.js";
5+
6+const WS_URL = "wss://stream.bybit.com/v5/public/spot";
7+const SYMBOLS = [...MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => `${b}USDT`), "USDTEUR", "USDCEUR", "USDTBRL", "USDTTRY", "USDTAED"];
8+
9+/** Bybit v5 public spot stream, `tickers.<symbol>` (snapshot + deltas with the full last price). Ping `{"op":"ping"}` every 20 s. */
10+export const bybitWs = defineConnector({
11+ metadata: {
12+ id: "bybit-ws",
13+ name: "Bybit — spot tickers (v5)",
14+ version: "1.0.0",
15+ sourceId: "bybit",
16+ organization: "Bybit",
17+ sourceType: "WEBSOCKET",
18+ jurisdiction: null,
19+ rightsStatus: "PUBLIC_ATTRIBUTED",
20+ realtimeStatus: "REALTIME",
21+ expectedLatencyMs: 500,
22+ supportsStreaming: true,
23+ supportsHistorical: false,
24+ assetClasses: ["CRYPTO", "FOREX"],
25+ exchanges: ["bybit"],
26+ homepage: "https://bybit-exchange.github.io/docs/v5/websocket/public/ticker",
27+ description: "Public Bybit v5 spot ticker stream (last price, 24h high/low/volume, previous 24h price) for USDT majors and the USDT/EUR, USDC/EUR, USDT/BRL, USDT/TRY, USDT/AED stablecoin markets (live FX proxies).",
28+ rightsNotes: "Public market data displayed with attribution to Bybit.",
29+ termsUrl: "https://www.bybit.com/en/terms-service/",
30+ sourceFamily: "bybit",
31+ enabled: true,
32+ },
33+ seeds: SYMBOLS.map((s) => {
34+ const [b, q] = splitVenueSymbol(s, null)!;
35+ return { symbol: s, hint: planPair(b, q, "bybit").hint };
36+ }),
37+ defaultSymbols: SYMBOLS,
38+ async start(ctx) {
39+ const ws = ctx.openWebSocket(WS_URL, {
40+ label: "bybit",
41+ staleAfterMs: 60_000,
42+ heartbeat: { intervalMs: 20_000, message: JSON.stringify({ op: "ping" }) },
43+ onOpen: (sock) => {
44+ // Bybit accepts at most 10 topics per subscribe request.
45+ const topics = ctx.watchedSymbols().map((s) => `tickers.${s}`);
46+ for (let i = 0; i < topics.length; i += 10) sock.send({ op: "subscribe", args: topics.slice(i, i + 10) });
47+ },
48+ onMessage: (data) => {
49+ let msg: any;
50+ try {
51+ msg = JSON.parse(data);
52+ } catch {
53+ return;
54+ }
55+ if (typeof msg?.topic === "string" && msg.topic.startsWith("tickers.") && msg.data) ctx.emit(raw("bybit-ws", "bybit", "ticker", msg));
56+ else if (msg?.success === false) ctx.reportError(new Error(String(msg.ret_msg ?? "bybit error")));
57+ },
58+ });
59+ ws.connect();
60+ },
61+ normalize(r): NormalizedBatch {
62+ const m = r.payload as { ts?: number; data?: Record<string, unknown>; cs?: number };
63+ const d = m.data;
64+ if (r.kind !== "ticker" || !d || typeof d.symbol !== "string") return { observations: [] };
65+ const split = splitVenueSymbol(d.symbol, null);
66+ if (!split) return { observations: [] };
67+ const [base, quote] = split;
68+ return {
69+ observations: pairObservations(d.symbol, base, quote, "bybit", { last: d.lastPrice, high: d.highPrice24h, low: d.lowPrice24h, volume: d.volume24h, open: d.prevPrice24h }, {
70+ sourceTimestamp: parseTimestamp(m.ts),
71+ timestampTrust: "EXCHANGE",
72+ sequence: typeof m.cs === "number" ? m.cs : null,
73+ rightsStatus: "PUBLIC_ATTRIBUTED",
74+ realtimeStatus: "REALTIME",
75+ }),
76+ };
77+ },
78+ fixturesDir: "fixtures",
79+});
modified connectors/src/cboe-delayed-quotes/index.ts +1 −1
@@ -79,7 +79,7 @@ export const cboeDelayedQuotes = defineConnector({
79 const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN; 79 const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
80 if (!Number.isFinite(n)) return; 80 if (!Number.isFinite(n)) return;
81 if ((field === "BID" || field === "ASK" || field === "LAST_PRICE" || field === "OPEN" || field === "HIGH" || field === "LOW" || field === "PREVIOUS_CLOSE") && n <= 0) return; 81 if ((field === "BID" || field === "ASK" || field === "LAST_PRICE" || field === "OPEN" || field === "HIGH" || field === "LOW" || field === "PREVIOUS_CLOSE") && n <= 0) return;
82− observations.push({ symbol, instrumentHint: hint, field, value: n, currency: "USD", sourceTimestamp: ts, timestampTrust: trust, sequence: seq, rightsStatus: "DELAYED", realtimeStatus: "DELAYED", meta: { security_type: d.security_type } }); 82+ observations.push({ symbol, instrumentHint: hint, field, value: n, currency: "USD", observationType: index ? "INDEX_VALUE" : field === "BID" || field === "ASK" ? "QUOTE" : "TRADE", sourceTimestamp: ts, timestampTrust: trust, sequence: seq, rightsStatus: "DELAYED", realtimeStatus: "DELAYED", meta: { security_type: d.security_type } });
83 }; 83 };
84 push("LAST_PRICE", d.current_price, tradeTs, tradeTs ? "EXCHANGE" : "SOURCE"); 84 push("LAST_PRICE", d.current_price, tradeTs, tradeTs ? "EXCHANGE" : "SOURCE");
85 push("OPEN", d.open, tradeTs, "EXCHANGE"); 85 push("OPEN", d.open, tradeTs, "EXCHANGE");
modified connectors/src/coinbase-ws/index.ts +9 −5
@@ -1,9 +1,13 @@
1 import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; 1 import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";
2 import { parseTimestamp } from "@market-atlas/market-model"; 2 import { parseTimestamp } from "@market-atlas/market-model";
3−import { MAJOR_BASES, cryptoSeeds, tickerObservations } from "../_shared/crypto.js"; 3+import { MAJOR_BASES, cryptoSeeds } from "../_shared/crypto.js";
4+import { pairObservations } from "../_shared/pairs.js";
4 5
5 const WS_URL = "wss://ws-feed.exchange.coinbase.com"; 6 const WS_URL = "wss://ws-feed.exchange.coinbase.com";
6−const PAIRS: Array<[string, string]> = [...MAJOR_BASES.map((b) => [b, "USD"] as [string, string]), ["BTC", "USDT"], ["ETH", "USDT"], ["BTC", "EUR"], ["ETH", "EUR"]]; 7+const CRYPTO_PAIRS: Array<[string, string]> = [...MAJOR_BASES.map((b) => [b, "USD"] as [string, string]), ["BTC", "USDT"], ["ETH", "USDT"], ["BTC", "EUR"], ["ETH", "EUR"]];
8+/** Stablecoin/fiat markets → live FX proxies (USDC ≈ USD). */
9+const FX_PROXY_PAIRS: Array<[string, string]> = [["USDC", "EUR"], ["USDC", "GBP"], ["USDT", "EUR"], ["USDT", "GBP"]];
10+const PAIRS = [...CRYPTO_PAIRS, ...FX_PROXY_PAIRS];
7 const SYMBOLS = PAIRS.map(([b, q]) => `${b}-${q}`); 11 const SYMBOLS = PAIRS.map(([b, q]) => `${b}-${q}`);
8 12
9 /** 13 /**
@@ -24,7 +28,7 @@ export const coinbaseWs = defineConnector({
24 expectedLatencyMs: 300, 28 expectedLatencyMs: 300,
25 supportsStreaming: true, 29 supportsStreaming: true,
26 supportsHistorical: false, 30 supportsHistorical: false,
27− assetClasses: ["CRYPTO"], 31+ assetClasses: ["CRYPTO", "FOREX"],
28 exchanges: ["coinbase"], 32 exchanges: ["coinbase"],
29 homepage: "https://docs.cdp.coinbase.com/exchange/docs/websocket-overview", 33 homepage: "https://docs.cdp.coinbase.com/exchange/docs/websocket-overview",
30 description: "Public, unauthenticated WebSocket market-data feed of Coinbase Exchange (ticker channel). One connection carries every subscribed product.", 34 description: "Public, unauthenticated WebSocket market-data feed of Coinbase Exchange (ticker channel). One connection carries every subscribed product.",
@@ -33,7 +37,7 @@ export const coinbaseWs = defineConnector({
33 sourceFamily: "coinbase", 37 sourceFamily: "coinbase",
34 enabled: true, 38 enabled: true,
35 }, 39 },
36− seeds: cryptoSeeds(PAIRS, "coinbase", (b, q) => `${b}-${q}`), 40+ seeds: cryptoSeeds(CRYPTO_PAIRS, "coinbase", (b, q) => `${b}-${q}`),
37 defaultSymbols: SYMBOLS, 41 defaultSymbols: SYMBOLS,
38 async start(ctx) { 42 async start(ctx) {
39 const ws = ctx.openWebSocket(WS_URL, { 43 const ws = ctx.openWebSocket(WS_URL, {
@@ -62,7 +66,7 @@ export const coinbaseWs = defineConnector({
62 const [base, quote] = m.product_id.split("-"); 66 const [base, quote] = m.product_id.split("-");
63 if (!base || !quote) return { observations: [] }; 67 if (!base || !quote) return { observations: [] };
64 return { 68 return {
65− observations: tickerObservations(m.product_id, base, quote, "coinbase", { last: m.price, open: m.open_24h, high: m.high_24h, low: m.low_24h, bid: m.best_bid, ask: m.best_ask, bidSize: m.best_bid_size, askSize: m.best_ask_size, volume: m.volume_24h }, { 69+ observations: pairObservations(m.product_id, base, quote, "coinbase", { last: m.price, open: m.open_24h, high: m.high_24h, low: m.low_24h, bid: m.best_bid, ask: m.best_ask, bidSize: m.best_bid_size, askSize: m.best_ask_size, volume: m.volume_24h }, {
66 sourceTimestamp: parseTimestamp(m.time), 70 sourceTimestamp: parseTimestamp(m.time),
67 timestampTrust: "EXCHANGE", 71 timestampTrust: "EXCHANGE",
68 sequence: typeof m.sequence === "number" ? m.sequence : null, 72 sequence: typeof m.sequence === "number" ? m.sequence : null,
modified connectors/src/connectors.test.ts +82 −0
@@ -245,3 +245,85 @@ describe("hfmarketdata-daily", () => {
245 expect(hfmarketdata.normalize({ connectorId: "hfmarketdata-daily", sourceId: "hfmarketdata", kind: "bars", payload: { data: [] }, receivedAt: 0, meta: { symbol: "NOPE" } }).observations).toHaveLength(0); 245 expect(hfmarketdata.normalize({ connectorId: "hfmarketdata-daily", sourceId: "hfmarketdata", kind: "bars", payload: { data: [] }, receivedAt: 0, meta: { symbol: "NOPE" } }).observations).toHaveLength(0);
246 }); 246 });
247 }); 247 });
248+
249+describe("source mesh v2 — venues, live FX and proxies", async () => {
250+ const { bitstampWs } = await import("./bitstamp-ws/index.js");
251+ const { geminiWs } = await import("./gemini-ws/index.js");
252+ const { bitfinexWs } = await import("./bitfinex-ws/index.js");
253+ const { bybitWs } = await import("./bybit-ws/index.js");
254+ const { gateWs } = await import("./gate-ws/index.js");
255+ const { cryptocomWs } = await import("./cryptocom-ws/index.js");
256+ const { kucoinWs } = await import("./kucoin-ws/index.js");
257+ const { nasdaqQuoteApi, parseNasdaqTime } = await import("./nasdaq-quote-api/index.js");
258+ const { planPair } = await import("./_shared/pairs.js");
259+
260+ it("plans venue pairs: crypto, real fiat markets, stablecoin proxies (with inversion)", () => {
261+ expect(planPair("BTC", "USD", "x").hint.assetClass).toBe("CRYPTO");
262+ const fx = planPair("EUR", "USD", "kraken");
263+ expect(fx.hint.assetClass).toBe("FOREX");
264+ expect(fx.type).toBe("TRADE");
265+ const inv = planPair("USDT", "EUR", "okx");
266+ expect(inv.type).toBe("STABLECOIN_PROXY");
267+ expect(inv.hint.base).toBe("EUR");
268+ expect(inv.invert).toBe(true);
269+ const brl = planPair("USDT", "BRL", "binance");
270+ expect(brl.hint.base).toBe("USD");
271+ expect(brl.hint.quote).toBe("BRL");
272+ expect(brl.invert).toBe(false);
273+ });
274+ it("kraken fiat ticker → FOREX TRADE observation on the conventional pair", () => {
275+ const b = normalizeFixture(krakenWs, "ticker", { channel: "ticker", type: "update", data: [{ symbol: "EUR/USD", bid: 1.1724, ask: 1.1726, last: 1.1725, volume: 120000.5, high: 1.175, low: 1.17 }] });
276+ const last = b.observations.find((o) => o.field === "LAST_PRICE")!;
277+ expect(last.instrumentHint?.assetClass).toBe("FOREX");
278+ expect(last.observationType).toBe("TRADE");
279+ expect(last.value).toBe(1.1725);
280+ });
281+ it("okx USDT-EUR ticker → inverted EURUSD stablecoin proxy", () => {
282+ const b = normalizeFixture(okxWs, "tickers", { arg: { channel: "tickers", instId: "USDT-EUR" }, data: [{ instId: "USDT-EUR", last: "0.8530", bidPx: "0.8529", askPx: "0.8531", ts: "1789199660123" }] });
283+ const last = b.observations.find((o) => o.field === "LAST_PRICE")!;
284+ expect(last.instrumentHint?.base).toBe("EUR");
285+ expect(last.observationType).toBe("STABLECOIN_PROXY");
286+ expect(last.value).toBeCloseTo(1 / 0.853, 6);
287+ const bid = b.observations.find((o) => o.field === "BID")!;
288+ expect(bid.value).toBeCloseTo(1 / 0.8531, 6); // venue ask becomes our bid
289+ expect(b.observations.some((o) => o.field === "VOLUME")).toBe(false);
290+ });
291+ it("bitstamp trade + book (EUR/USD fiat market)", () => {
292+ const t = normalizeFixture(bitstampWs, "trade", fx("bitstamp-ws", "trade.json"));
293+ expect(t.observations[0]).toMatchObject({ field: "LAST_PRICE", value: 77203.59, observationType: "TRADE" });
294+ expect(t.observations[0]!.sourceTimestamp).toBe(1789273894413);
295+ const bk = normalizeFixture(bitstampWs, "book", fx("bitstamp-ws", "book.json"));
296+ expect(bk.observations.find((o) => o.field === "BID")?.value).toBe(1.17252);
297+ expect(bk.observations[0]!.instrumentHint?.assetClass).toBe("FOREX");
298+ });
299+ it("gemini, bitfinex, bybit, gate, crypto.com, kucoin tickers", () => {
300+ expect(normalizeFixture(geminiWs, "trade", fx("gemini-ws", "trade.json")).observations[0]).toMatchObject({ field: "LAST_PRICE", value: 77212.78, sourceTimestamp: 1789274085234 });
301+ const bf = normalizeFixture(bitfinexWs, "ticker", fx("bitfinex-ws", "ticker.json"));
302+ expect(bf.observations.find((o) => o.field === "LAST_PRICE")?.value).toBe(77118);
303+ expect(bf.observations.find((o) => o.field === "BID")?.value).toBe(77123);
304+ expect(normalizeFixture(bybitWs, "ticker", fx("bybit-ws", "ticker.json")).observations.find((o) => o.field === "LAST_PRICE")?.value).toBe(77229.4);
305+ expect(normalizeFixture(gateWs, "ticker", fx("gate-ws", "ticker.json")).observations.find((o) => o.field === "ASK")?.value).toBe(77227.8);
306+ expect(normalizeFixture(cryptocomWs, "ticker", fx("cryptocom-ws", "ticker.json")).observations.find((o) => o.field === "LAST_PRICE")?.value).toBe(77219.2);
307+ const ku = normalizeFixture(kucoinWs, "ticker", fx("kucoin-ws", "ticker.json"));
308+ expect(ku.observations.find((o) => o.field === "LAST_PRICE")?.value).toBe(77225.1);
309+ expect(ku.observations[0]!.sourceTimestamp).toBe(1789274087087);
310+ });
311+ it("nasdaq.com quote is a restricted validator with parsed Eastern timestamps", () => {
312+ const b = normalizeFixture(nasdaqQuoteApi, "info", fx("nasdaq-quote-api", "info.json"));
313+ const last = b.observations.find((o) => o.field === "LAST_PRICE")!;
314+ expect(last.value).toBe(332.27);
315+ expect(last.rightsStatus).toBe("PUBLIC_RESTRICTED_REDISTRIBUTION");
316+ expect(new Date(last.sourceTimestamp!).toISOString()).toBe("2026-09-10T20:00:00.000Z");
317+ expect(b.observations.find((o) => o.field === "VOLUME")?.value).toBe(50716997);
318+ expect(parseNasdaqTime("Sep 12, 2026 9:45 AM ET")).toBe(Date.parse("2026-09-12T13:45:00Z"));
319+ expect(parseNasdaqTime("garbage")).toBeNull();
320+ });
321+ it("every streaming connector still opens exactly one socket", async () => {
322+ for (const def of [bitstampWs, geminiWs, bitfinexWs, bybitWs, gateWs, cryptocomWs]) {
323+ const ctx = makeTestContext(def);
324+ await def.start!(ctx);
325+ expect(ctx.sockets).toHaveLength(1);
326+ ctx.sockets[0]!.close();
327+ }
328+ });
329+});
added connectors/src/cryptocom-ws/README.md +7 −0
@@ -0,0 +1,7 @@
1+# cryptocom-ws
2+
3+Crypto.com Exchange public market stream (`wss://stream.crypto.com/exchange/v1/market`), channels `ticker.<INSTRUMENT>`.
4+
5+- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (tick `t`)
6+- **Fields**: LAST_PRICE (`a`), BID/ASK (`b`/`k` + sizes), HIGH/LOW (24 h), VOLUME (24 h).
7+- **Protocol quirks**: subscribe only ~1 s after the connection opens (documented); the server sends `public/heartbeat` frames that must be answered with `public/respond-heartbeat` or the connection is dropped.
added connectors/src/cryptocom-ws/fixtures/ticker.json +1 −0
@@ -0,0 +1 @@
1+{"instrument_name":"BTC_USD","subscription":"ticker.BTC_USD","channel":"ticker","data":[{"h":"77506.01","l":"77056.96","a":"77219.20","c":"-0.0007","b":"77219.19","bs":"0.47190","k":"77219.20","ks":"0.29415","i":"BTC_USD","v":"1023.4412","vv":"79012345.12","oi":"0","t":1789273912345}]}
added connectors/src/cryptocom-ws/index.ts +77 −0
@@ -0,0 +1,77 @@
1+import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";
2+import { parseTimestamp } from "@market-atlas/market-model";
3+import { MAJOR_BASES } from "../_shared/crypto.js";
4+import { pairObservations, planPair } from "../_shared/pairs.js";
5+
6+const WS_URL = "wss://stream.crypto.com/exchange/v1/market";
7+const SYMBOLS = [...MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => `${b}_USD`), "BTC_USDT", "ETH_USDT"];
8+
9+/** Crypto.com Exchange public market stream, `ticker.<instrument>`. The server sends `public/heartbeat` that must be answered. */
10+export const cryptocomWs = defineConnector({
11+ metadata: {
12+ id: "cryptocom-ws",
13+ name: "Crypto.com Exchange — ticker channel",
14+ version: "1.0.0",
15+ sourceId: "cryptocom",
16+ organization: "Crypto.com",
17+ sourceType: "WEBSOCKET",
18+ jurisdiction: "SG",
19+ rightsStatus: "PUBLIC_ATTRIBUTED",
20+ realtimeStatus: "REALTIME",
21+ expectedLatencyMs: 500,
22+ supportsStreaming: true,
23+ supportsHistorical: false,
24+ assetClasses: ["CRYPTO"],
25+ exchanges: ["cryptocom"],
26+ homepage: "https://exchange-docs.crypto.com/exchange/v1/rest-ws/index.html#ticker-instrument_name",
27+ description: "Public Crypto.com Exchange ticker stream (last, best bid/ask with sizes, 24h high/low/volume) for USD-quoted majors and BTC/ETH in USDT.",
28+ rightsNotes: "Public market data displayed with attribution to Crypto.com Exchange.",
29+ termsUrl: "https://crypto.com/exchange/document/terms-of-service",
30+ sourceFamily: "cryptocom",
31+ enabled: true,
32+ },
33+ seeds: SYMBOLS.map((s) => {
34+ const [b, q] = s.split("_") as [string, string];
35+ return { symbol: s, hint: planPair(b, q, "cryptocom").hint };
36+ }),
37+ defaultSymbols: SYMBOLS,
38+ async start(ctx) {
39+ const ws = ctx.openWebSocket(WS_URL, {
40+ label: "cryptocom",
41+ staleAfterMs: 60_000,
42+ heartbeat: null, // server-initiated heartbeats answered below
43+ onOpen: (sock) => setTimeout(() => sock.send({ id: 1, method: "subscribe", params: { channels: ctx.watchedSymbols().map((s) => `ticker.${s}`) } }), 1000), // docs: wait 1 s after connect
44+ onMessage: (data, sock) => {
45+ let msg: any;
46+ try {
47+ msg = JSON.parse(data);
48+ } catch {
49+ return;
50+ }
51+ if (msg?.method === "public/heartbeat") return void sock.send({ id: msg.id, method: "public/respond-heartbeat" });
52+ if (msg?.method === "subscribe" && msg.result?.channel === "ticker" && Array.isArray(msg.result.data)) ctx.emit(raw("cryptocom-ws", "cryptocom", "ticker", msg.result));
53+ else if (msg?.code && msg.code !== 0) ctx.reportError(new Error(String(msg.message ?? `cryptocom code ${msg.code}`)));
54+ },
55+ });
56+ ws.connect();
57+ },
58+ normalize(r): NormalizedBatch {
59+ const m = r.payload as { instrument_name?: string; data?: Array<Record<string, unknown>> };
60+ if (r.kind !== "ticker" || typeof m.instrument_name !== "string" || !Array.isArray(m.data)) return { observations: [] };
61+ const [base, quote] = m.instrument_name.split("_");
62+ if (!base || !quote) return { observations: [] };
63+ const observations = [];
64+ for (const t of m.data) {
65+ observations.push(
66+ ...pairObservations(m.instrument_name, base, quote, "cryptocom", { last: t.a, bid: t.b, bidSize: t.bs, ask: t.k, askSize: t.ks, high: t.h, low: t.l, volume: t.v }, {
67+ sourceTimestamp: parseTimestamp(t.t),
68+ timestampTrust: "EXCHANGE",
69+ rightsStatus: "PUBLIC_ATTRIBUTED",
70+ realtimeStatus: "REALTIME",
71+ }),
72+ );
73+ }
74+ return { observations };
75+ },
76+ fixturesDir: "fixtures",
77+});
modified connectors/src/ecb-frankfurter/index.ts +1 −1
@@ -85,7 +85,7 @@ export const ecbFrankfurter = defineConnector({
85 const [b, q] = conventionalPair(p.base!, ccy); 85 const [b, q] = conventionalPair(p.base!, ccy);
86 if (p.base === "USD" && (b === "EUR" || q === "EUR")) continue; 86 if (p.base === "USD" && (b === "EUR" || q === "EUR")) continue;
87 const value = b === p.base ? rate : 1 / rate; 87 const value = b === p.base ? rate : 1 / rate;
88− observations.push({ symbol: `${b}${q}`, instrumentHint: fxHint(b, q), field, value: Number(value.toPrecision(8)), currency: q, sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { fixing_date: date, reference_date: refDate, base: p.base } }); 88+ observations.push({ symbol: `${b}${q}`, instrumentHint: fxHint(b, q), field, value: Number(value.toPrecision(8)), currency: q, observationType: field === "PREVIOUS_CLOSE" ? "OFFICIAL_FIX" : "OFFICIAL_FIX", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { fixing_date: date, reference_date: refDate, base: p.base } });
89 } 89 }
90 }; 90 };
91 emit(latest, "LAST_PRICE", latest); 91 emit(latest, "LAST_PRICE", latest);
added connectors/src/gate-ws/README.md +7 −0
@@ -0,0 +1,7 @@
1+# gate-ws
2+
3+Gate.io spot WebSocket v4 (`wss://api.gateio.ws/ws/v4/`), channel `spot.tickers`.
4+
5+- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (frame `time_ms`)
6+- **Fields**: LAST_PRICE, BID (`highest_bid`), ASK (`lowest_ask`), HIGH/LOW (24 h), VOLUME (24 h base).
7+- **Heartbeat**: `{"channel":"spot.ping"}` every 20 s. Updates arrive roughly every 5 s per pair.
added connectors/src/gate-ws/fixtures/ticker.json +1 −0
@@ -0,0 +1 @@
1+{"time":1789273915,"time_ms":1789273915820,"channel":"spot.tickers","event":"update","result":{"currency_pair":"BTC_USDT","last":"77227.8","lowest_ask":"77227.8","highest_bid":"77227.7","change_percentage":"-0.06","base_volume":"1304.867845","quote_volume":"100800000.1","high_24h":"77520.0","low_24h":"77000.1"}}
added connectors/src/gate-ws/index.ts +73 −0
@@ -0,0 +1,73 @@
1+import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";
2+import { parseTimestamp } from "@market-atlas/market-model";
3+import { MAJOR_BASES } from "../_shared/crypto.js";
4+import { pairObservations, planPair } from "../_shared/pairs.js";
5+
6+const WS_URL = "wss://api.gateio.ws/ws/v4/";
7+const SYMBOLS = MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => `${b}_USDT`);
8+
9+/** Gate.io spot WebSocket v4, `spot.tickers` channel. Ping via `spot.ping`. */
10+export const gateWs = defineConnector({
11+ metadata: {
12+ id: "gate-ws",
13+ name: "Gate — spot tickers (WS v4)",
14+ version: "1.0.0",
15+ sourceId: "gate",
16+ organization: "Gate Technology Inc.",
17+ sourceType: "WEBSOCKET",
18+ jurisdiction: null,
19+ rightsStatus: "PUBLIC_ATTRIBUTED",
20+ realtimeStatus: "REALTIME",
21+ expectedLatencyMs: 1000,
22+ supportsStreaming: true,
23+ supportsHistorical: false,
24+ assetClasses: ["CRYPTO"],
25+ exchanges: ["gate"],
26+ homepage: "https://www.gate.io/docs/developers/apiv4/ws/en/#tickers-channel",
27+ description: "Public Gate.io spot ticker channel (last, best bid/ask, 24h high/low/volume) for USDT majors.",
28+ rightsNotes: "Public market data displayed with attribution to Gate.",
29+ termsUrl: "https://www.gate.io/user-agreement",
30+ sourceFamily: "gate",
31+ enabled: true,
32+ },
33+ seeds: SYMBOLS.map((s) => {
34+ const [b, q] = s.split("_") as [string, string];
35+ return { symbol: s, hint: planPair(b, q, "gate").hint };
36+ }),
37+ defaultSymbols: SYMBOLS,
38+ async start(ctx) {
39+ const ws = ctx.openWebSocket(WS_URL, {
40+ label: "gate",
41+ staleAfterMs: 60_000,
42+ heartbeat: { intervalMs: 20_000, message: JSON.stringify({ time: Math.floor(Date.now() / 1000), channel: "spot.ping" }) },
43+ onOpen: (sock) => sock.send({ time: Math.floor(Date.now() / 1000), channel: "spot.tickers", event: "subscribe", payload: ctx.watchedSymbols() }),
44+ onMessage: (data) => {
45+ let msg: any;
46+ try {
47+ msg = JSON.parse(data);
48+ } catch {
49+ return;
50+ }
51+ if (msg?.channel === "spot.tickers" && msg.event === "update" && msg.result) ctx.emit(raw("gate-ws", "gate", "ticker", msg));
52+ else if (msg?.error) ctx.reportError(new Error(String(msg.error.message ?? "gate error")));
53+ },
54+ });
55+ ws.connect();
56+ },
57+ normalize(r): NormalizedBatch {
58+ const m = r.payload as { time_ms?: number; result?: Record<string, unknown> };
59+ const d = m.result;
60+ if (r.kind !== "ticker" || !d || typeof d.currency_pair !== "string") return { observations: [] };
61+ const [base, quote] = d.currency_pair.split("_");
62+ if (!base || !quote) return { observations: [] };
63+ return {
64+ observations: pairObservations(d.currency_pair, base, quote, "gate", { last: d.last, bid: d.highest_bid, ask: d.lowest_ask, high: d.high_24h, low: d.low_24h, volume: d.base_volume }, {
65+ sourceTimestamp: parseTimestamp(m.time_ms),
66+ timestampTrust: "EXCHANGE",
67+ rightsStatus: "PUBLIC_ATTRIBUTED",
68+ realtimeStatus: "REALTIME",
69+ }),
70+ };
71+ },
72+ fixturesDir: "fixtures",
73+});
added connectors/src/gemini-ws/README.md +7 −0
@@ -0,0 +1,7 @@
1+# gemini-ws
2+
3+Gemini market data v2 (`wss://api.gemini.com/v2/marketdata`), `l2` subscription.
4+
5+- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (exchange `timestamp` ms)
6+- **Output**: LAST_PRICE from `trade` events (the initial `l2_updates` snapshot carries recent trades → the newest is emitted). Order-book deltas are not aggregated (no BID/ASK from this connector).
7+- **Cadence**: trade-driven — quiet pairs update rarely; the stale threshold is therefore 3 minutes.
added connectors/src/gemini-ws/fixtures/trade.json +1 −0
@@ -0,0 +1 @@
1+{"type":"trade","symbol":"BTCUSD","event_id":1789274085123456,"timestamp":1789274085234,"price":"77212.78","quantity":"0.0125","side":"buy"}
added connectors/src/gemini-ws/index.ts +73 −0
@@ -0,0 +1,73 @@
1+import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";
2+import { parseTimestamp } from "@market-atlas/market-model";
3+import { pairObservations, planPair, splitVenueSymbol } from "../_shared/pairs.js";
4+
5+const WS_URL = "wss://api.gemini.com/v2/marketdata";
6+const SYMBOLS = ["BTCUSD", "ETHUSD", "SOLUSD", "XRPUSD", "LTCUSD", "LINKUSD", "DOGEUSD", "AVAXUSD", "DOTUSD", "BTCGUSD"];
7+
8+/** Gemini market data v2, `l2` subscription: initial snapshot with recent trades, then `trade` events. */
9+export const geminiWs = defineConnector({
10+ metadata: {
11+ id: "gemini-ws",
12+ name: "Gemini — market data v2 (trades)",
13+ version: "1.0.0",
14+ sourceId: "gemini",
15+ organization: "Gemini Trust Company, LLC",
16+ sourceType: "WEBSOCKET",
17+ jurisdiction: "US",
18+ rightsStatus: "PUBLIC_ATTRIBUTED",
19+ realtimeStatus: "REALTIME",
20+ expectedLatencyMs: 400,
21+ supportsStreaming: true,
22+ supportsHistorical: false,
23+ assetClasses: ["CRYPTO"],
24+ exchanges: ["gemini"],
25+ homepage: "https://docs.gemini.com/websocket-api/#market-data-version-2",
26+ description: "Public Gemini market-data v2 feed (l2 subscription): last trades for the major USD pairs with exchange timestamps.",
27+ rightsNotes: "Public market data displayed with attribution to Gemini.",
28+ termsUrl: "https://www.gemini.com/legal/api-agreement",
29+ sourceFamily: "gemini",
30+ enabled: true,
31+ },
32+ seeds: SYMBOLS.map((s) => {
33+ const [b, q] = splitVenueSymbol(s, null)!;
34+ return { symbol: s, hint: planPair(b, q, "gemini").hint, aliases: [`${b}-${q}`, `${b}/${q}`] };
35+ }),
36+ defaultSymbols: SYMBOLS,
37+ async start(ctx) {
38+ const ws = ctx.openWebSocket(WS_URL, {
39+ label: "gemini",
40+ staleAfterMs: 180_000,
41+ heartbeat: { intervalMs: 25_000 },
42+ onOpen: (sock) => sock.send({ type: "subscribe", subscriptions: [{ name: "l2", symbols: ctx.watchedSymbols() }] }),
43+ onMessage: (data) => {
44+ let msg: any;
45+ try {
46+ msg = JSON.parse(data);
47+ } catch {
48+ return;
49+ }
50+ if (msg?.type === "trade") ctx.emit(raw("gemini-ws", "gemini", "trade", msg));
51+ else if (msg?.type === "l2_updates" && Array.isArray(msg.trades) && msg.trades.length) ctx.emit(raw("gemini-ws", "gemini", "trade", { ...msg.trades[msg.trades.length - 1], symbol: msg.symbol, type: "trade" }));
52+ },
53+ });
54+ ws.connect();
55+ },
56+ normalize(r): NormalizedBatch {
57+ const m = r.payload as Record<string, unknown>;
58+ if (r.kind !== "trade" || typeof m.symbol !== "string") return { observations: [] };
59+ const split = splitVenueSymbol(m.symbol, null);
60+ if (!split) return { observations: [] };
61+ const [base, quote] = split;
62+ return {
63+ observations: pairObservations(m.symbol, base, quote, "gemini", { last: m.price }, {
64+ sourceTimestamp: parseTimestamp(m.timestamp),
65+ timestampTrust: "EXCHANGE",
66+ sequence: typeof m.event_id === "number" ? m.event_id : null,
67+ rightsStatus: "PUBLIC_ATTRIBUTED",
68+ realtimeStatus: "REALTIME",
69+ }),
70+ };
71+ },
72+ fixturesDir: "fixtures",
73+});
modified connectors/src/hfmarketdata/index.ts +1 −1
@@ -124,7 +124,7 @@ export const hfmarketdata = defineConnector({
124 const observations: NormalizedObservation[] = []; 124 const observations: NormalizedObservation[] = [];
125 if (last) { 125 if (last) {
126 const ts = zonedTimeToUtc(`${last.date}T${target.sessionCloseLocal.length === 5 ? `${target.sessionCloseLocal}:00` : target.sessionCloseLocal}`, target.timezone); 126 const ts = zonedTimeToUtc(`${last.date}T${target.sessionCloseLocal.length === 5 ? `${target.sessionCloseLocal}:00` : target.sessionCloseLocal}`, target.timezone);
127− const base = { symbol: target.symbol, instrumentHint: target.hint, currency: target.hint.currency ?? "USD", sourceTimestamp: ts, timestampTrust: "SOURCE" as const, rightsStatus: "LICENSED" as const, realtimeStatus: "END_OF_DAY" as const, meta: { session: last.date, contract: last.contract } }; 127+ const base = { symbol: target.symbol, instrumentHint: target.hint, currency: target.hint.currency ?? "USD", observationType: "EOD_CLOSE" as const, sourceTimestamp: ts, timestampTrust: "SOURCE" as const, rightsStatus: "LICENSED" as const, realtimeStatus: "END_OF_DAY" as const, meta: { session: last.date, contract: last.contract } };
128 observations.push({ ...base, field: "LAST_PRICE", value: last.close! }, { ...base, field: "CLOSE", value: last.close! }, { ...base, field: "OPEN", value: last.open! }, { ...base, field: "HIGH", value: last.high! }, { ...base, field: "LOW", value: last.low! }); 128 observations.push({ ...base, field: "LAST_PRICE", value: last.close! }, { ...base, field: "CLOSE", value: last.close! }, { ...base, field: "OPEN", value: last.open! }, { ...base, field: "HIGH", value: last.high! }, { ...base, field: "LOW", value: last.low! });
129 if (last.volume != null) observations.push({ ...base, field: "VOLUME", value: last.volume }); 129 if (last.volume != null) observations.push({ ...base, field: "VOLUME", value: last.volume });
130 if (prev) observations.push({ ...base, field: "PREVIOUS_CLOSE", value: prev.close!, meta: { session: prev.date } }); 130 if (prev) observations.push({ ...base, field: "PREVIOUS_CLOSE", value: prev.close!, meta: { session: prev.date } });
modified connectors/src/index.ts +16 −0
@@ -13,6 +13,14 @@ import { nasdaqSymbolDirectory } from "./nasdaq-symbol-directory/index.js";
13 import { nasdaqTradeHalts } from "./nasdaq-trade-halts/index.js"; 13 import { nasdaqTradeHalts } from "./nasdaq-trade-halts/index.js";
14 import { nasdaqMarketCalendar } from "./nasdaq-market-calendar/index.js"; 14 import { nasdaqMarketCalendar } from "./nasdaq-market-calendar/index.js";
15 import { hfmarketdata } from "./hfmarketdata/index.js"; 15 import { hfmarketdata } from "./hfmarketdata/index.js";
16+import { bitstampWs } from "./bitstamp-ws/index.js";
17+import { geminiWs } from "./gemini-ws/index.js";
18+import { bitfinexWs } from "./bitfinex-ws/index.js";
19+import { bybitWs } from "./bybit-ws/index.js";
20+import { gateWs } from "./gate-ws/index.js";
21+import { cryptocomWs } from "./cryptocom-ws/index.js";
22+import { kucoinWs } from "./kucoin-ws/index.js";
23+import { nasdaqQuoteApi } from "./nasdaq-quote-api/index.js";
16 24
17 export { SOURCES, sourceById } from "./sources.js"; 25 export { SOURCES, sourceById } from "./sources.js";
18 26
@@ -28,7 +36,15 @@ export const CONNECTORS: ConnectorDefinition[] = [
28 krakenWs, 36 krakenWs,
29 binanceWs, 37 binanceWs,
30 okxWs, 38 okxWs,
39+ bitstampWs,
40+ geminiWs,
41+ bitfinexWs,
42+ bybitWs,
43+ gateWs,
44+ cryptocomWs,
45+ kucoinWs,
31 cboeDelayedQuotes, 46 cboeDelayedQuotes,
47+ nasdaqQuoteApi,
32 ecbFrankfurter, 48 ecbFrankfurter,
33 bankOfCanada, 49 bankOfCanada,
34 usTreasuryYieldCurve, 50 usTreasuryYieldCurve,
modified connectors/src/kraken-ws/index.ts +12 −6
@@ -1,9 +1,15 @@
1 import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; 1 import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";
2−import { MAJOR_BASES, cryptoSeeds, tickerObservations } from "../_shared/crypto.js"; 2+import { MAJOR_BASES, cryptoSeeds } from "../_shared/crypto.js";
3+import { pairObservations, planPair } from "../_shared/pairs.js";
4+import type { ProposedInstrument } from "@market-atlas/connector-sdk";
3 5
4 const WS_URL = "wss://ws.kraken.com/v2"; 6 const WS_URL = "wss://ws.kraken.com/v2";
5−const PAIRS: Array<[string, string]> = [...MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USD"] as [string, string]), ["BTC", "USDT"], ["ETH", "USDT"], ["BTC", "EUR"], ["ETH", "EUR"]]; 7+const CRYPTO_PAIRS: Array<[string, string]> = [...MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USD"] as [string, string]), ["BTC", "USDT"], ["ETH", "USDT"], ["BTC", "EUR"], ["ETH", "EUR"]];
8+/** Kraken runs real fiat/fiat spot markets — a live, keyless FX source. */
9+const FIAT_PAIRS: Array<[string, string]> = [["EUR", "USD"], ["GBP", "USD"], ["USD", "JPY"], ["USD", "CAD"], ["USD", "CHF"], ["AUD", "USD"], ["EUR", "GBP"], ["EUR", "JPY"], ["EUR", "CHF"], ["EUR", "CAD"], ["EUR", "AUD"], ["AUD", "JPY"]];
10+const PAIRS = [...CRYPTO_PAIRS, ...FIAT_PAIRS];
6 const SYMBOLS = PAIRS.map(([b, q]) => `${b}/${q}`); 11 const SYMBOLS = PAIRS.map(([b, q]) => `${b}/${q}`);
12+const fiatSeeds: ProposedInstrument[] = FIAT_PAIRS.map(([b, q]) => ({ symbol: `${b}/${q}`, hint: planPair(b, q, "kraken").hint }));
7 13
8 /** Kraken WebSocket API v2, `ticker` channel (event trigger: trades). No per-message timestamp → connector receive time. */ 14 /** Kraken WebSocket API v2, `ticker` channel (event trigger: trades). No per-message timestamp → connector receive time. */
9 export const krakenWs = defineConnector({ 15 export const krakenWs = defineConnector({
@@ -20,16 +26,16 @@ export const krakenWs = defineConnector({
20 expectedLatencyMs: 400, 26 expectedLatencyMs: 400,
21 supportsStreaming: true, 27 supportsStreaming: true,
22 supportsHistorical: false, 28 supportsHistorical: false,
23− assetClasses: ["CRYPTO"], 29+ assetClasses: ["CRYPTO", "FOREX"],
24 exchanges: ["kraken"], 30 exchanges: ["kraken"],
25 homepage: "https://docs.kraken.com/api/docs/websocket-v2/ticker", 31 homepage: "https://docs.kraken.com/api/docs/websocket-v2/ticker",
26− description: "Public Kraken WebSocket v2 ticker channel (best bid/ask, last trade, 24h volume/VWAP/high/low) for the major spot pairs.", 32+ description: "Public Kraken WebSocket v2 ticker channel (best bid/ask, last trade, 24h volume/VWAP/high/low) for the major crypto spot pairs and Kraken's 12 fiat/fiat markets (EUR/USD, USD/JPY, GBP/USD, USD/CAD…) — a live, keyless FX source.",
27 rightsNotes: "Public market data displayed with attribution to Kraken.", 33 rightsNotes: "Public market data displayed with attribution to Kraken.",
28 termsUrl: "https://www.kraken.com/legal", 34 termsUrl: "https://www.kraken.com/legal",
29 sourceFamily: "kraken", 35 sourceFamily: "kraken",
30 enabled: true, 36 enabled: true,
31 }, 37 },
32− seeds: cryptoSeeds(PAIRS, "kraken", (b, q) => `${b}/${q}`), 38+ seeds: [...cryptoSeeds(CRYPTO_PAIRS, "kraken", (b, q) => `${b}/${q}`), ...fiatSeeds],
33 defaultSymbols: SYMBOLS, 39 defaultSymbols: SYMBOLS,
34 async start(ctx) { 40 async start(ctx) {
35 const ws = ctx.openWebSocket(WS_URL, { 41 const ws = ctx.openWebSocket(WS_URL, {
@@ -59,7 +65,7 @@ export const krakenWs = defineConnector({
59 const [base, quote] = t.symbol.split("/"); 65 const [base, quote] = t.symbol.split("/");
60 if (!base || !quote) continue; 66 if (!base || !quote) continue;
61 observations.push( 67 observations.push(
62− ...tickerObservations(t.symbol, base, quote, "kraken", { last: t.last, high: t.high, low: t.low, bid: t.bid, ask: t.ask, bidSize: t.bid_qty, askSize: t.ask_qty, volume: t.volume, vwap: t.vwap }, { 68+ ...pairObservations(t.symbol, base, quote, "kraken", { last: t.last, high: t.high, low: t.low, bid: t.bid, ask: t.ask, bidSize: t.bid_qty, askSize: t.ask_qty, volume: t.volume, vwap: t.vwap }, {
63 sourceTimestamp: null, 69 sourceTimestamp: null,
64 timestampTrust: "CONNECTOR", 70 timestampTrust: "CONNECTOR",
65 rightsStatus: "PUBLIC_ATTRIBUTED", 71 rightsStatus: "PUBLIC_ATTRIBUTED",
added connectors/src/kucoin-ws/README.md +8 −0
@@ -0,0 +1,8 @@
1+# kucoin-ws
2+
3+KuCoin spot public WebSocket, topic `/market/ticker:<SYMBOLS>`. The endpoint + token come from `POST https://api.kucoin.com/api/v1/bullet-public` (no account, no key).
4+
5+- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (tick `time`)
6+- **Fields**: LAST_PRICE, BID/ASK with sizes. No 24 h stats in this topic.
7+- **Heartbeat**: `{"type":"ping"}` at the server-advertised `pingInterval` minus 3 s.
8+- **Pairs**: USDT majors + EUR-USDT (FX proxy).
added connectors/src/kucoin-ws/fixtures/ticker.json +1 −0
@@ -0,0 +1 @@
1+{"topic":"/market/ticker:BTC-USDT","type":"message","subject":"trade.ticker","data":{"bestAsk":"77225.2","bestAskSize":"0.35133999","bestBid":"77225.1","bestBidSize":"0.37973782","price":"77225.1","sequence":"37014680999","size":"0.00002491","time":1789274087087}}
added connectors/src/kucoin-ws/index.ts +85 −0
@@ -0,0 +1,85 @@
1+import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";
2+import { parseTimestamp } from "@market-atlas/market-model";
3+import { MAJOR_BASES } from "../_shared/crypto.js";
4+import { pairObservations, planPair } from "../_shared/pairs.js";
5+
6+const BULLET_URL = "https://api.kucoin.com/api/v1/bullet-public";
7+const SYMBOLS = [...MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => `${b}-USDT`), "EUR-USDT"];
8+
9+/**
10+ * KuCoin spot ticker (`/market/ticker:<symbols>`). The public WebSocket endpoint is obtained through a
11+ * keyless "bullet" token request (no account), then one connection carries every symbol.
12+ */
13+export const kucoinWs = defineConnector({
14+ metadata: {
15+ id: "kucoin-ws",
16+ name: "KuCoin — spot ticker",
17+ version: "1.0.0",
18+ sourceId: "kucoin",
19+ organization: "KuCoin",
20+ sourceType: "WEBSOCKET",
21+ jurisdiction: "SC",
22+ rightsStatus: "PUBLIC_ATTRIBUTED",
23+ realtimeStatus: "REALTIME",
24+ expectedLatencyMs: 500,
25+ supportsStreaming: true,
26+ supportsHistorical: false,
27+ assetClasses: ["CRYPTO", "FOREX"],
28+ exchanges: ["kucoin"],
29+ homepage: "https://www.kucoin.com/docs/websocket/spot-trading/public-channels/ticker",
30+ description: "Public KuCoin spot ticker topic (last price, best bid/ask with sizes) for USDT majors and the EUR/USDT stablecoin market. Endpoint discovered via the keyless public bullet token.",
31+ rightsNotes: "Public market data displayed with attribution to KuCoin.",
32+ termsUrl: "https://www.kucoin.com/legal/terms-of-use",
33+ sourceFamily: "kucoin",
34+ enabled: true,
35+ },
36+ seeds: SYMBOLS.map((s) => {
37+ const [b, q] = s.split("-") as [string, string];
38+ return { symbol: s, hint: planPair(b, q, "kucoin").hint };
39+ }),
40+ defaultSymbols: SYMBOLS,
41+ rateLimits: { "api.kucoin.com": 1 },
42+ async start(ctx) {
43+ const res = await ctx.http.request(BULLET_URL, { method: "POST", conditional: false, timeoutMs: 15_000, headers: { "content-type": "application/json" } });
44+ const bullet = JSON.parse(res.text) as { data?: { token?: string; instanceServers?: Array<{ endpoint: string; pingInterval?: number }> } };
45+ const server = bullet.data?.instanceServers?.[0];
46+ const token = bullet.data?.token;
47+ if (!server || !token) throw new Error("kucoin bullet token unavailable");
48+ const url = `${server.endpoint}?token=${encodeURIComponent(token)}&connectId=market-atlas`;
49+ const ws = ctx.openWebSocket(url, {
50+ label: "kucoin",
51+ staleAfterMs: 60_000,
52+ heartbeat: { intervalMs: Math.max(5000, Math.min(server.pingInterval ?? 18_000, 30_000) - 3000), message: JSON.stringify({ id: "hb", type: "ping" }) },
53+ onOpen: (sock) => sock.send({ id: "1", type: "subscribe", topic: `/market/ticker:${ctx.watchedSymbols().join(",")}`, response: true }),
54+ onMessage: (data) => {
55+ let msg: any;
56+ try {
57+ msg = JSON.parse(data);
58+ } catch {
59+ return;
60+ }
61+ if (msg?.type === "message" && typeof msg.topic === "string" && msg.topic.startsWith("/market/ticker:") && msg.data) ctx.emit(raw("kucoin-ws", "kucoin", "ticker", msg));
62+ else if (msg?.type === "error") ctx.reportError(new Error(String(msg.data ?? "kucoin error")));
63+ },
64+ });
65+ ws.connect();
66+ },
67+ normalize(r): NormalizedBatch {
68+ const m = r.payload as { topic?: string; data?: Record<string, unknown> };
69+ const d = m.data;
70+ if (r.kind !== "ticker" || typeof m.topic !== "string" || !d) return { observations: [] };
71+ const symbol = m.topic.slice("/market/ticker:".length);
72+ const [base, quote] = symbol.split("-");
73+ if (!base || !quote) return { observations: [] };
74+ return {
75+ observations: pairObservations(symbol, base, quote, "kucoin", { last: d.price, bid: d.bestBid, bidSize: d.bestBidSize, ask: d.bestAsk, askSize: d.bestAskSize }, {
76+ sourceTimestamp: parseTimestamp(d.time),
77+ timestampTrust: "EXCHANGE",
78+ sequence: typeof d.sequence === "string" ? d.sequence : null,
79+ rightsStatus: "PUBLIC_ATTRIBUTED",
80+ realtimeStatus: "REALTIME",
81+ }),
82+ };
83+ },
84+ fixturesDir: "fixtures",
85+});
added connectors/src/nasdaq-quote-api/README.md +8 −0
@@ -0,0 +1,8 @@
1+# nasdaq-quote-api — validation only
2+
3+`https://api.nasdaq.com/api/quote/<SYMBOL>/info?assetclass=stocks|etf` — the JSON behind nasdaq.com quote pages (delayed).
4+
5+- **Type**: XHR · **Rights**: PUBLIC_RESTRICTED_REDISTRIBUTION → **validator role**: the consensus engine excludes it from the canonical value (`reason: validation_only`) but counts its agreement (`validator_count`) and flags disagreement (`validator_disagrees`). Values are never shown or streamed.
6+- **Family**: `nasdaq` — independent from Cboe, so a Cboe/Nasdaq agreement is a real cross-check for US equities.
7+- **Timestamps**: `lastTradeTimestamp` like "Sep 12, 2026 4:00 PM ET" or "Sep 10, 2026" (→ 16:00 ET) parsed with `parseNasdaqTime`.
8+- **Schedule**: every 5 min while Nasdaq is open (0.5 req/s → full universe in ~2.5 min), hourly closed, every 6 h on weekends.
added connectors/src/nasdaq-quote-api/fixtures/info.json +1 −0
@@ -0,0 +1 @@
1+{"data":{"symbol":"AAPL","companyName":"Apple Inc. Common Stock","stockType":"Common Stock","exchange":"NASDAQ-GS","isNasdaqListed":true,"isNasdaq100":true,"isHeld":false,"primaryData":{"lastSalePrice":"$332.27","netChange":"+5.70","percentageChange":"+1.75%","deltaIndicator":"up","lastTradeTimestamp":"Sep 10, 2026 4:00 PM ET","isRealTime":false,"bidPrice":"$332.20","askPrice":"$332.35","bidSize":"100","askSize":"200","volume":"50,716,997","currency":null},"secondaryData":null,"marketStatus":"Closed","assetClass":"STOCKS"},"message":null,"status":{"rCode":200,"bCodeMessage":null,"developerMessage":null}}
added connectors/src/nasdaq-quote-api/index.ts +95 −0
@@ -0,0 +1,95 @@
1+import { defineConnector, parseNumber, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";
2+import type { NormalizedObservation, RawObservation } from "@market-atlas/market-model";
3+import { zonedTimeToUtc } from "@market-atlas/market-model";
4+import { US_EQUITIES, US_ETFS } from "../_shared/us-universe.js";
5+
6+const BASE = "https://api.nasdaq.com/api/quote";
7+const ET = "America/New_York";
8+const SYMBOLS = [...US_EQUITIES.map(([s]) => s), ...US_ETFS.map(([s]) => s)];
9+const MONTHS: Record<string, number> = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
10+
11+/**
12+ * Nasdaq.com public quote JSON (the XHR behind nasdaq.com quote pages; delayed). Rights classified as
13+ * PUBLIC_RESTRICTED_REDISTRIBUTION → Market Atlas uses it as a *validator* only: it confirms or contests
14+ * the canonical price and feeds confidence, but its values are never redistributed.
15+ */
16+export const nasdaqQuoteApi = defineConnector({
17+ metadata: {
18+ id: "nasdaq-quote-api",
19+ name: "Nasdaq.com — delayed quote (validation only)",
20+ version: "1.0.0",
21+ sourceId: "nasdaq-com",
22+ organization: "Nasdaq, Inc.",
23+ sourceType: "XHR",
24+ jurisdiction: "US",
25+ rightsStatus: "PUBLIC_RESTRICTED_REDISTRIBUTION",
26+ realtimeStatus: "DELAYED",
27+ expectedLatencyMs: 15 * 60_000,
28+ supportsStreaming: false,
29+ supportsHistorical: false,
30+ assetClasses: ["EQUITY", "ETF"],
31+ exchanges: ["xnas", "xnys", "arcx"],
32+ homepage: "https://www.nasdaq.com/market-activity/stocks",
33+ description: "Delayed last sale, bid/ask and volume from nasdaq.com's public quote JSON for the curated US universe. Used for cross-validation of the canonical price only (independent Nasdaq family); values are withheld from public responses.",
34+ rightsNotes: "Nasdaq.com terms restrict redistribution: internal validation only, never displayed or streamed.",
35+ termsUrl: "https://www.nasdaq.com/terms-of-use",
36+ sourceFamily: "nasdaq",
37+ enabled: true,
38+ },
39+ defaultSymbols: SYMBOLS,
40+ rateLimits: { "api.nasdaq.com": 0.5 },
41+ schedule: { openMs: 5 * 60_000, closedMs: 60 * 60_000, weekendMs: 6 * 60 * 60_000, exchangeId: "xnas" },
42+ async poll(ctx) {
43+ const out: RawObservation[] = [];
44+ const etfs = new Set(US_ETFS.map(([s]) => s));
45+ for (const sym of ctx.watchedSymbols()) {
46+ try {
47+ const { data, response } = await ctx.http.getJson<Record<string, unknown>>(`${BASE}/${encodeURIComponent(sym)}/info?assetclass=${etfs.has(sym) ? "etf" : "stocks"}`, {
48+ timeoutMs: 30_000,
49+ // The Akamai front of api.nasdaq.com only answers browser-like clients.
50+ headers: { accept: "application/json, text/plain, */*", "accept-language": "en-US,en;q=0.9", origin: "https://www.nasdaq.com", referer: "https://www.nasdaq.com/", "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36" },
51+ });
52+ if (response.notModified) continue;
53+ out.push(raw("nasdaq-quote-api", "nasdaq-com", "info", data, { symbol: sym }));
54+ } catch (err) {
55+ ctx.reportError(err, { symbol: sym });
56+ }
57+ }
58+ return out;
59+ },
60+ normalize(r): NormalizedBatch {
61+ const p = r.payload as { data?: { symbol?: string; primaryData?: Record<string, unknown>; marketStatus?: string } };
62+ const d = p?.data;
63+ const pd = d?.primaryData;
64+ if (r.kind !== "info" || !d || typeof d.symbol !== "string" || !pd) return { observations: [] };
65+ const ts = parseNasdaqTime(String(pd.lastTradeTimestamp ?? ""));
66+ const observations: NormalizedObservation[] = [];
67+ const push = (field: NormalizedObservation["field"], v: unknown, type: NormalizedObservation["observationType"]) => {
68+ const n = parseNumber(typeof v === "string" ? v.replace(/^\$/, "") : v);
69+ if (n == null || (field !== "VOLUME" && n <= 0)) return;
70+ observations.push({ symbol: d.symbol!, field, value: n, currency: "USD", observationType: type, sourceTimestamp: ts, timestampTrust: ts ? "SOURCE" : "CONNECTOR", rightsStatus: "PUBLIC_RESTRICTED_REDISTRIBUTION", realtimeStatus: "DELAYED", meta: { market_status: d.marketStatus, is_real_time: pd.isRealTime } });
71+ };
72+ push("LAST_PRICE", pd.lastSalePrice, "TRADE");
73+ push("BID", pd.bidPrice, "QUOTE");
74+ push("ASK", pd.askPrice, "QUOTE");
75+ push("VOLUME", pd.volume, "TRADE");
76+ push("CHANGE", pd.netChange, "TRADE");
77+ return { observations };
78+ },
79+ fixturesDir: "fixtures",
80+});
81+
82+/** "Sep 12, 2026 4:00 PM ET" or "Sep 10, 2026" (closing) → UTC ms. */
83+export function parseNasdaqTime(s: string): number | null {
84+ const m = s.match(/^([A-Za-z]{3})\.?\s+(\d{1,2}),\s*(\d{4})(?:\s+(\d{1,2}):(\d{2})\s*(AM|PM))?/i);
85+ if (!m) return null;
86+ const mo = MONTHS[m[1]!.toLowerCase()];
87+ if (!mo) return null;
88+ let h = 16;
89+ let mi = 0;
90+ if (m[4]) {
91+ h = Number(m[4]) % 12 + (m[6]!.toUpperCase() === "PM" ? 12 : 0);
92+ mi = Number(m[5]);
93+ }
94+ return zonedTimeToUtc(`${m[3]}-${String(mo).padStart(2, "0")}-${m[2]!.padStart(2, "0")}T${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}:00`, ET);
95+}
modified connectors/src/okx-ws/index.ts +9 −5
@@ -1,9 +1,13 @@
1 import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; 1 import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk";
2 import { parseTimestamp } from "@market-atlas/market-model"; 2 import { parseTimestamp } from "@market-atlas/market-model";
3−import { MAJOR_BASES, cryptoSeeds, tickerObservations } from "../_shared/crypto.js"; 3+import { MAJOR_BASES, cryptoSeeds } from "../_shared/crypto.js";
4+import { pairObservations } from "../_shared/pairs.js";
4 5
5 const WS_URL = "wss://ws.okx.com:8443/ws/v5/public"; 6 const WS_URL = "wss://ws.okx.com:8443/ws/v5/public";
6−const PAIRS: Array<[string, string]> = MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USDT"] as [string, string]).concat([["TRX", "USDT"]]); 7+const CRYPTO_PAIRS: Array<[string, string]> = MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USDT"] as [string, string]).concat([["TRX", "USDT"]]);
8+/** USDT markets against fiat → live FX proxies (STABLECOIN_PROXY). */
9+const FX_PROXY_PAIRS: Array<[string, string]> = [["USDT", "EUR"], ["USDT", "AUD"], ["USDT", "BRL"], ["USDT", "SGD"], ["USDT", "TRY"], ["USDT", "AED"]];
10+const PAIRS = [...CRYPTO_PAIRS, ...FX_PROXY_PAIRS];
7 const SYMBOLS = PAIRS.map(([b, q]) => `${b}-${q}`); 11 const SYMBOLS = PAIRS.map(([b, q]) => `${b}-${q}`);
8 12
9 /** OKX public WebSocket v5, `tickers` channel. Requires a text "ping" at least every 30 s. */ 13 /** OKX public WebSocket v5, `tickers` channel. Requires a text "ping" at least every 30 s. */
@@ -21,7 +25,7 @@ export const okxWs = defineConnector({
21 expectedLatencyMs: 500, 25 expectedLatencyMs: 500,
22 supportsStreaming: true, 26 supportsStreaming: true,
23 supportsHistorical: false, 27 supportsHistorical: false,
24− assetClasses: ["CRYPTO"], 28+ assetClasses: ["CRYPTO", "FOREX"],
25 exchanges: ["okx"], 29 exchanges: ["okx"],
26 homepage: "https://www.okx.com/docs-v5/en/#public-data-websocket-tickers-channel", 30 homepage: "https://www.okx.com/docs-v5/en/#public-data-websocket-tickers-channel",
27 description: "Public OKX v5 WebSocket tickers channel (last, bid/ask with sizes, 24h open/high/low/volume) for USDT spot majors. Exchange timestamp `ts`.", 31 description: "Public OKX v5 WebSocket tickers channel (last, bid/ask with sizes, 24h open/high/low/volume) for USDT spot majors. Exchange timestamp `ts`.",
@@ -30,7 +34,7 @@ export const okxWs = defineConnector({
30 sourceFamily: "okx", 34 sourceFamily: "okx",
31 enabled: true, 35 enabled: true,
32 }, 36 },
33− seeds: cryptoSeeds(PAIRS, "okx", (b, q) => `${b}-${q}`), 37+ seeds: cryptoSeeds(CRYPTO_PAIRS, "okx", (b, q) => `${b}-${q}`),
34 defaultSymbols: SYMBOLS, 38 defaultSymbols: SYMBOLS,
35 async start(ctx) { 39 async start(ctx) {
36 const ws = ctx.openWebSocket(WS_URL, { 40 const ws = ctx.openWebSocket(WS_URL, {
@@ -61,7 +65,7 @@ export const okxWs = defineConnector({
61 const [base, quote] = t.instId.split("-"); 65 const [base, quote] = t.instId.split("-");
62 if (!base || !quote) continue; 66 if (!base || !quote) continue;
63 observations.push( 67 observations.push(
64− ...tickerObservations(t.instId, base, quote, "okx", { last: t.last, open: t.open24h, high: t.high24h, low: t.low24h, bid: t.bidPx, ask: t.askPx, bidSize: t.bidSz, askSize: t.askSz, volume: t.vol24h }, { 68+ ...pairObservations(t.instId, base, quote, "okx", { last: t.last, open: t.open24h, high: t.high24h, low: t.low24h, bid: t.bidPx, ask: t.askPx, bidSize: t.bidSz, askSize: t.askSz, volume: t.vol24h }, {
65 sourceTimestamp: parseTimestamp(t.ts), 69 sourceTimestamp: parseTimestamp(t.ts),
66 timestampTrust: "EXCHANGE", 70 timestampTrust: "EXCHANGE",
67 rightsStatus: "PUBLIC_ATTRIBUTED", 71 rightsStatus: "PUBLIC_ATTRIBUTED",
modified connectors/src/sources.ts +8 −0
@@ -10,6 +10,14 @@ export const SOURCES: SourceRecord[] = [
10 { id: "kraken", name: "Kraken", organization: "Payward, Inc.", sourceType: "WEBSOCKET", homepage: "https://www.kraken.com", jurisdiction: "US", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "kraken", category: "EXCHANGE", enabled: true }, 10 { id: "kraken", name: "Kraken", organization: "Payward, Inc.", sourceType: "WEBSOCKET", homepage: "https://www.kraken.com", jurisdiction: "US", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "kraken", category: "EXCHANGE", enabled: true },
11 { id: "binance", name: "Binance", organization: "Binance", sourceType: "WEBSOCKET", homepage: "https://www.binance.com", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "binance", category: "EXCHANGE", enabled: true }, 11 { id: "binance", name: "Binance", organization: "Binance", sourceType: "WEBSOCKET", homepage: "https://www.binance.com", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "binance", category: "EXCHANGE", enabled: true },
12 { id: "okx", name: "OKX", organization: "OKX", sourceType: "WEBSOCKET", homepage: "https://www.okx.com", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "okx", category: "EXCHANGE", enabled: true }, 12 { id: "okx", name: "OKX", organization: "OKX", sourceType: "WEBSOCKET", homepage: "https://www.okx.com", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "okx", category: "EXCHANGE", enabled: true },
13+ { id: "bitstamp", name: "Bitstamp", organization: "Bitstamp Ltd", sourceType: "WEBSOCKET", homepage: "https://www.bitstamp.net", jurisdiction: "GB", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "bitstamp", category: "EXCHANGE", enabled: true },
14+ { id: "gemini", name: "Gemini", organization: "Gemini Trust Company, LLC", sourceType: "WEBSOCKET", homepage: "https://www.gemini.com", jurisdiction: "US", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "gemini", category: "EXCHANGE", enabled: true },
15+ { id: "bitfinex", name: "Bitfinex", organization: "iFinex Inc.", sourceType: "WEBSOCKET", homepage: "https://www.bitfinex.com", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "bitfinex", category: "EXCHANGE", enabled: true },
16+ { id: "bybit", name: "Bybit", organization: "Bybit", sourceType: "WEBSOCKET", homepage: "https://www.bybit.com", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "bybit", category: "EXCHANGE", enabled: true },
17+ { id: "gate", name: "Gate", organization: "Gate Technology Inc.", sourceType: "WEBSOCKET", homepage: "https://www.gate.io", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "gate", category: "EXCHANGE", enabled: true },
18+ { id: "cryptocom", name: "Crypto.com Exchange", organization: "Crypto.com", sourceType: "WEBSOCKET", homepage: "https://crypto.com/exchange", jurisdiction: "SG", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "cryptocom", category: "EXCHANGE", enabled: true },
19+ { id: "kucoin", name: "KuCoin", organization: "KuCoin", sourceType: "WEBSOCKET", homepage: "https://www.kucoin.com", jurisdiction: "SC", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "kucoin", category: "EXCHANGE", enabled: true },
20+ { id: "nasdaq-com", name: "Nasdaq.com — delayed quotes (validation only)", organization: "Nasdaq, Inc.", sourceType: "XHR", homepage: "https://www.nasdaq.com/market-activity", jurisdiction: "US", rightsStatus: "PUBLIC_RESTRICTED_REDISTRIBUTION", realtimeStatus: "DELAYED", family: "nasdaq", category: "EXCHANGE", enabled: true },
13 { id: "cboe", name: "Cboe Global Markets — delayed quotes", organization: "Cboe Global Markets", sourceType: "XHR", homepage: "https://www.cboe.com", jurisdiction: "US", rightsStatus: "DELAYED", realtimeStatus: "DELAYED", family: "cboe", category: "EXCHANGE", enabled: true }, 21 { id: "cboe", name: "Cboe Global Markets — delayed quotes", organization: "Cboe Global Markets", sourceType: "XHR", homepage: "https://www.cboe.com", jurisdiction: "US", rightsStatus: "DELAYED", realtimeStatus: "DELAYED", family: "cboe", category: "EXCHANGE", enabled: true },
14 { id: "ecb-frankfurter", name: "ECB euro foreign exchange reference rates (via Frankfurter)", organization: "European Central Bank", sourceType: "OFFICIAL_API", homepage: "https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html", jurisdiction: "EU", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", family: "ecb", category: "CENTRAL_BANK", enabled: true }, 22 { id: "ecb-frankfurter", name: "ECB euro foreign exchange reference rates (via Frankfurter)", organization: "European Central Bank", sourceType: "OFFICIAL_API", homepage: "https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html", jurisdiction: "EU", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", family: "ecb", category: "CENTRAL_BANK", enabled: true },
15 { id: "bank-of-canada", name: "Bank of Canada Valet API", organization: "Bank of Canada", sourceType: "OFFICIAL_API", homepage: "https://www.bankofcanada.ca/valet/docs", jurisdiction: "CA", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", family: "bank-of-canada", category: "CENTRAL_BANK", enabled: true }, 23 { id: "bank-of-canada", name: "Bank of Canada Valet API", organization: "Bank of Canada", sourceType: "OFFICIAL_API", homepage: "https://www.bankofcanada.ca/valet/docs", jurisdiction: "CA", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", family: "bank-of-canada", category: "CENTRAL_BANK", enabled: true },
modified connectors/src/us-treasury-yield-curve/index.ts +1 −1
@@ -82,7 +82,7 @@ export const usTreasuryYieldCurve = defineConnector({
82 const v = last.values[key]; 82 const v = last.values[key];
83 if (v == null) continue; 83 if (v == null) continue;
84 const ts = zonedTimeToUtc(`${last.date!.slice(0, 10)}T15:30:00`, "America/New_York"); 84 const ts = zonedTimeToUtc(`${last.date!.slice(0, 10)}T15:30:00`, "America/New_York");
85− const base = { symbol, instrumentHint: hint(symbol, name), currency: "USD", sourceTimestamp: ts, timestampTrust: "SOURCE" as const, rightsStatus: "OFFICIAL_OPEN_DATA" as const, realtimeStatus: "END_OF_DAY" as const, meta: { date: last.date!.slice(0, 10) } }; 85+ const base = { symbol, instrumentHint: hint(symbol, name), currency: "USD", observationType: "REFERENCE_RATE" as const, sourceTimestamp: ts, timestampTrust: "SOURCE" as const, rightsStatus: "OFFICIAL_OPEN_DATA" as const, realtimeStatus: "END_OF_DAY" as const, meta: { date: last.date!.slice(0, 10) } };
86 observations.push({ ...base, field: "YIELD", value: v }, { ...base, field: "LAST_PRICE", value: v }); 86 observations.push({ ...base, field: "YIELD", value: v }, { ...base, field: "LAST_PRICE", value: v });
87 const pv = prev?.values[key]; 87 const pv = prev?.values[key];
88 if (pv != null) observations.push({ ...base, field: "PREVIOUS_CLOSE", value: pv, meta: { date: prev!.date!.slice(0, 10) } }); 88 if (pv != null) observations.push({ ...base, field: "PREVIOUS_CLOSE", value: pv, meta: { date: prev!.date!.slice(0, 10) } });
modified docs/API.md +5 −1
@@ -8,6 +8,8 @@ Quote object (`publicQuote`): `instrument_id, symbol, name, asset_class, exchang
8 change, change_percent, volume, bid, ask, currency, source_count, dispersion_bps, confidence, freshness_ms, data_status, market_state, 8 change, change_percent, volume, bid, ask, currency, source_count, dispersion_bps, confidence, freshness_ms, data_status, market_state,
9 rights_status, withheld, updated_at, source_timestamp, session_high, session_low`. 9 rights_status, withheld, updated_at, source_timestamp, session_high, session_low`.
10 `data_status` ∈ `REALTIME | DELAYED | END_OF_DAY | AT_CLOSE | STALE | WITHHELD` — never present STALE/AT_CLOSE as live. 10 `data_status` ∈ `REALTIME | DELAYED | END_OF_DAY | AT_CLOSE | STALE | WITHHELD` — never present STALE/AT_CLOSE as live.
11+Quotes also carry `observation_count`, `proxy_count`, `validator_count`, `comparability` (LIVE/FIX/EOD); provenance contributions carry
12+`observation_type`, `delta_bps`, `reason` ∈ stale | not_comparable | temporal_mismatch | validation_only | validator_disagrees | outlier.
11 13
12 | Method & path | Purpose | 14 | Method & path | Purpose |
13 |---|---| 15 |---|---|
@@ -29,7 +31,9 @@ rights_status, withheld, updated_at, source_timestamp, session_high, session_low
29 | `GET /exchanges` · `GET /exchanges/:id` | venues, status/next transition, holidays, breadth, movers, events | 31 | `GET /exchanges` · `GET /exchanges/:id` | venues, status/next transition, holidays, breadth, movers, events |
30 | `GET /countries` · `GET /countries/:code` | country atlas page data | 32 | `GET /countries` · `GET /countries/:code` | country atlas page data |
31 | `GET /compare?ids=a,b,c&resolution=1d&limit` | normalized series, return/vol/drawdown, correlations | 33 | `GET /compare?ids=a,b,c&resolution=1d&limit` | normalized series, return/vol/drawdown, correlations |
32−| `GET /sources` · `GET /connectors` · `GET /data-health` | source directory, public connector table, data-health dashboard | 34+| `GET /sources` · `GET /connectors` · `GET /data-health` | source directory (with `role` contributor/validator and `likely_shared_upstream_with`), public connector table, data-health dashboard |
35+| `GET /coverage?asset_class&tier&limit` · `GET /coverage/:id` | Source Coverage Engine: redundancy histogram (≥5/≥3/≥2/1 families), weighted score, tier attainment, expansion queue, inferred shared upstreams; per-instrument coverage |
36+| `GET /admin/lineage` | all statistically compared source pairs with similarity |
33 | `GET /stream` (WebSocket) · `GET /sse?channels=` | live stream — see below | 37 | `GET /stream` (WebSocket) · `GET /sse?channels=` | live stream — see below |
34 | `GET /admin/overview|connectors|connectors/:id|schema-changes|divergence|storage|raw?ref=` · `POST /admin/connectors/:id/pause|resume|restart|test` · `POST /admin/connectors/:id/schema-changes/:changeId/ack` · `POST /admin/discovery {url}` | admin console | 38 | `GET /admin/overview|connectors|connectors/:id|schema-changes|divergence|storage|raw?ref=` · `POST /admin/connectors/:id/pause|resume|restart|test` · `POST /admin/connectors/:id/schema-changes/:changeId/ack` · `POST /admin/discovery {url}` | admin console |
35 39
modified docs/ARCHITECTURE.md +18 −3
@@ -35,13 +35,28 @@ derives events, keeps the history, and redistributes the result through one web
35 - **L3** — `observations` (partitioned by `received_at` day; fingerprint dedupes reconnect replays; `normalizer_version`; REALTIME streaming ticks are persisted at most once per source/instrument/field per `MA_TICK_PERSIST_INTERVAL_MS` = 2 s while consensus/events see every tick). Retention `MA_OBSERVATION_RETENTION_DAYS` then gzip NDJSON archive in `MA_DATA_DIR/archive` (never silently deleted; `observation_archives` index). 35 - **L3** — `observations` (partitioned by `received_at` day; fingerprint dedupes reconnect replays; `normalizer_version`; REALTIME streaming ticks are persisted at most once per source/instrument/field per `MA_TICK_PERSIST_INTERVAL_MS` = 2 s while consensus/events see every tick). Retention `MA_OBSERVATION_RETENTION_DAYS` then gzip NDJSON archive in `MA_DATA_DIR/archive` (never silently deleted; `observation_archives` index).
36 - **L4** — `canonical_quotes`, `bars`, `market_events`, `filings`, `connector_health`, `fact_changes`, `document_snapshots`. 36 - **L4** — `canonical_quotes`, `bars`, `market_events`, `filings`, `connector_health`, `fact_changes`, `document_snapshots`.
37 37
38+## Source Mesh v2 (2026-09-13)
39+
40+**One instrument → many observers → one canonical market state.** Every observation carries an `observation_type`
41+(TRADE, MID, QUOTE, INDEX_VALUE, INDICATIVE, STABLECOIN_PROXY, DERIVED, OFFICIAL_FIX, REFERENCE_RATE, SETTLEMENT, EOD_CLOSE, NAV) that
42+maps to a comparability class (LIVE / FIX / EOD). Only comparable observations are compared: an ECB fixing is never measured against a live
43+market, last week's close is never "divergent" from yesterday's. Roles: real markets **vote**; stablecoin/derived proxies **confirm**
44+(reduced weight, never counted as families); restricted-rights sources (nasdaq.com) **validate** only (`validation_only`, never voting,
45+never redistributed). `core/coverage.ts` scores every quoted instrument against a tier target (A ≥ 3 families / 5 observations; B ≥ 2 / 3;
46+C 1 / 2; D official single source) and produces the source-expansion queue (`GET /v1/coverage`). `core/lineage.ts` infers shared upstreams
47+statistically (identical values within 2 s over ≥ 120 aligned samples → similarity ≥ 0.92 → one family, `source_lineage` table).
48+Live FX without keys: Kraken fiat markets (12 pairs, TRADE), Bitstamp EUR/USD & GBP/USD, Bitfinex EUR/GBP/JPY vs USD, plus stablecoin
49+proxies (EUR/USDT, USDC/EUR, USDT/BRL…) on Binance, OKX, Coinbase, Bybit, Gate, KuCoin.
50+
38 ## Consensus (apps/api/src/core/consensus.ts) 51 ## Consensus (apps/api/src/core/consensus.ts)
39 52
40 Per instrument and field, the newest observation of each source is kept (out-of-order values ignored). A value is *fresh* within a 53 Per instrument and field, the newest observation of each source is kept (out-of-order values ignored). A value is *fresh* within a
41 window depending on its real-time class (REALTIME 15 s, DELAYED 30 min, INDICATIVE 1 h, END_OF_DAY 10 d; 4 d for live classes while the 54 window depending on its real-time class (REALTIME 15 s, DELAYED 30 min, INDICATIVE 1 h, END_OF_DAY 10 d; 4 d for live classes while the
42−venue is closed — the last session value is reported as AT_CLOSE, never as live). Weight = source reliability × timestamp trust × real-time class × freshness decay × official bonus. One vote per 55+venue is closed — the last session value is reported as AT_CLOSE, never as live). Comparability class first (LIVE from real markets > FIX >
43−**source family** (sources believed to share an upstream count once). When live classes exist, END_OF_DAY/INDICATIVE values are 56+EOD > live proxies alone), then temporal tolerance inside the class (live: 2 min; fixings/closes: same UTC day → `temporal_mismatch`).
44−*superseded*. Outliers (> 2 % from the weighted median with ≥ 3 candidates) are excluded. Output: weighted median, dispersion (bps), 57+Weight = source reliability × timestamp trust × real-time class × freshness decay × official bonus (×1.6 for rates). One vote per
58+**source family** (declared or statistically inferred). Crypto weights scale with the venue's share of 24 h notional volume.
59+Outliers (> 2 % from the weighted median with ≥ 3 candidates) are excluded. Output: weighted median, dispersion (bps),
45 independent-family count, freshness, confidence (agreement + redundancy + freshness + reliability, capped at 0.995), and the full 60 independent-family count, freshness, confidence (agreement + redundancy + freshness + reliability, capped at 0.995), and the full
46 contribution list with inclusion reasons — exposed as **"Why this price?"** (`GET /v1/quotes/:id/provenance`). 61 contribution list with inclusion reasons — exposed as **"Why this price?"** (`GET /v1/quotes/:id/provenance`).
47 62
modified docs/CONNECTORS.md +7 −3
@@ -43,9 +43,13 @@ Side outputs of `normalize()`:
43 | `holidays` | market-hours engine (`exchange_holidays`) | 43 | `holidays` | market-hours engine (`exchange_holidays`) |
44 | `bars` | historical backfill (`bars`, producer = connector id) | 44 | `bars` | historical backfill (`bars`, producer = connector id) |
45 45
46−Production connectors (v0.1): coinbase-ws, kraken-ws, binance-ws, okx-ws (WEBSOCKET) · cboe-delayed-quotes (XHR) · ecb-frankfurter, 46+Production connectors (v0.2, 22): coinbase-ws, kraken-ws (+12 fiat pairs), binance-ws, okx-ws, bitstamp-ws (+EUR/USD, GBP/USD), gemini-ws,
47−bank-of-canada-valet, hfmarketdata-daily (OFFICIAL_API) · us-treasury-yield-curve (XML) · sec-edgar-filings, nasdaq-trade-halts (RSS) · 47+bitfinex-ws (+EUR/GBP/JPY vs USD), bybit-ws, gate-ws, cryptocom-ws, kucoin-ws (WEBSOCKET) · cboe-delayed-quotes, nasdaq-quote-api (validator)
48−sec-company-tickers, nasdaq-symbol-directory (BULK_FILE) · nasdaq-market-calendar (HTML change detection). See each `README.md`. 48+(XHR) · ecb-frankfurter, bank-of-canada-valet, hfmarketdata-daily (OFFICIAL_API) · us-treasury-yield-curve (XML) · sec-edgar-filings,
49+nasdaq-trade-halts (RSS) · sec-company-tickers, nasdaq-symbol-directory (BULK_FILE) · nasdaq-market-calendar (HTML). All keyless
50+(hfmarketdata accepts an optional key of the sister platform). Declare `observationType` on every observation (TRADE / QUOTE / OFFICIAL_FIX /
51+REFERENCE_RATE / EOD_CLOSE / STABLECOIN_PROXY…); use `_shared/pairs.ts` (`planPair`, `pairObservations`) for venue pairs so fiat/fiat markets
52+become FOREX TRADE observations and stable/fiat markets become STABLECOIN_PROXY observations (inverted when needed). See each `README.md`.
49 53
50 Onboarding workflow for a *discovered* source: `POST /v1/admin/discovery {url}` → candidate report (endpoints, embedded state, price/symbol-like 54 Onboarding workflow for a *discovered* source: `POST /v1/admin/discovery {url}` → candidate report (endpoints, embedded state, price/symbol-like
51 fields) → manual rights review → connector + fixtures → staging (`enabled: false` or `MA_DISABLED_CONNECTORS`) → production. 55 fields) → manual rights review → connector + fixtures → staging (`enabled: false` or `MA_DISABLED_CONNECTORS`) → production.
added infra/migrations/0002_observation_types.sql +17 −0
@@ -0,0 +1,17 @@
1+-- Observation types (comparability classes) + canonical quote coverage columns.
2+alter table observations add column if not exists observation_type text;
3+alter table canonical_quotes add column if not exists observation_count integer not null default 0;
4+alter table canonical_quotes add column if not exists proxy_count integer not null default 0;
5+alter table canonical_quotes add column if not exists validator_count integer not null default 0;
6+alter table canonical_quotes add column if not exists comparability text;
7+
8+-- Inferred source lineage (statistical shared-upstream detection).
9+create table if not exists source_lineage (
10+ source_a text not null,
11+ source_b text not null,
12+ similarity double precision not null,
13+ samples integer not null,
14+ instruments integer not null,
15+ computed_at timestamptz not null default now(),
16+ primary key (source_a, source_b)
17+);
modified packages/market-model/src/enums.ts +38 −0
@@ -93,6 +93,44 @@ export const FIELDS = [
93 ] as const; 93 ] as const;
94 export type Field = (typeof FIELDS)[number]; 94 export type Field = (typeof FIELDS)[number];
95 95
96+/**
97+ * What an observation *is*. Values of different comparability classes are never compared to each
98+ * other (an ECB fixing is not a live mid, an exchange settlement is not a trade).
99+ */
100+export const OBSERVATION_TYPES = [
101+ "TRADE", // last trade on a venue
102+ "MID", // mid of a quoted market
103+ "QUOTE", // bid/ask style value
104+ "INDEX_VALUE", // computed index level
105+ "INDICATIVE", // indicative live value (non-tradable)
106+ "STABLECOIN_PROXY", // fiat rate implied from a stablecoin market (USDT/USDC ≈ USD)
107+ "DERIVED", // computed by Market Atlas (cross rates…)
108+ "OFFICIAL_FIX", // central-bank / official fixing
109+ "REFERENCE_RATE", // official reference or policy rate
110+ "SETTLEMENT", // exchange settlement price
111+ "EOD_CLOSE", // end-of-day close from a historical dataset
112+ "NAV",
113+] as const;
114+export type ObservationType = (typeof OBSERVATION_TYPES)[number];
115+
116+export type ComparabilityClass = "LIVE" | "FIX" | "EOD";
117+export const COMPARABILITY: Record<ObservationType, ComparabilityClass> = {
118+ TRADE: "LIVE",
119+ MID: "LIVE",
120+ QUOTE: "LIVE",
121+ INDEX_VALUE: "LIVE",
122+ INDICATIVE: "LIVE",
123+ STABLECOIN_PROXY: "LIVE",
124+ DERIVED: "LIVE",
125+ OFFICIAL_FIX: "FIX",
126+ REFERENCE_RATE: "FIX",
127+ SETTLEMENT: "EOD",
128+ EOD_CLOSE: "EOD",
129+ NAV: "EOD",
130+};
131+/** Observation types that are proxies/derivations: they confirm but never count as independent families. */
132+export const PROXY_TYPES: ReadonlySet<ObservationType> = new Set<ObservationType>(["STABLECOIN_PROXY", "DERIVED", "INDICATIVE"]);
133+
96 export const TIMESTAMP_TRUSTS = ["EXCHANGE", "SOURCE", "CONNECTOR", "UNKNOWN"] as const; 134 export const TIMESTAMP_TRUSTS = ["EXCHANGE", "SOURCE", "CONNECTOR", "UNKNOWN"] as const;
97 export type TimestampTrust = (typeof TIMESTAMP_TRUSTS)[number]; 135 export type TimestampTrust = (typeof TIMESTAMP_TRUSTS)[number];
98 136
modified packages/market-model/src/schemas.ts +2 −0
@@ -3,6 +3,7 @@ import {
3 ASSET_CLASSES, 3 ASSET_CLASSES,
4 EVENT_TYPES, 4 EVENT_TYPES,
5 FIELDS, 5 FIELDS,
6+ OBSERVATION_TYPES,
6 REALTIME_STATUSES, 7 REALTIME_STATUSES,
7 RIGHTS_STATUSES, 8 RIGHTS_STATUSES,
8 SEVERITIES, 9 SEVERITIES,
@@ -32,6 +33,7 @@ export const NormalizedObservationSchema = z.object({
32 field: z.enum(FIELDS), 33 field: z.enum(FIELDS),
33 value: z.number().finite(), 34 value: z.number().finite(),
34 currency: z.string().nullable().optional(), 35 currency: z.string().nullable().optional(),
36+ observationType: z.enum(OBSERVATION_TYPES).optional(),
35 sourceTimestamp: z.number().int().nullable(), 37 sourceTimestamp: z.number().int().nullable(),
36 timestampTrust: z.enum(TIMESTAMP_TRUSTS), 38 timestampTrust: z.enum(TIMESTAMP_TRUSTS),
37 sequence: z.union([z.number(), z.string()]).nullable().optional(), 39 sequence: z.union([z.number(), z.string()]).nullable().optional(),
modified packages/market-model/src/types.ts +16 −2
@@ -5,6 +5,7 @@ import type {
5 EventType, 5 EventType,
6 Field, 6 Field,
7 MarketState, 7 MarketState,
8+ ObservationType,
8 RealtimeStatus, 9 RealtimeStatus,
9 RightsStatus, 10 RightsStatus,
10 Severity, 11 Severity,
@@ -160,6 +161,8 @@ export interface NormalizedObservation {
160 field: Field; 161 field: Field;
161 value: number; 162 value: number;
162 currency?: string | null; 163 currency?: string | null;
164+ /** Nature of the value (TRADE, OFFICIAL_FIX, EOD_CLOSE…). Inferred from realtimeStatus when omitted. */
165+ observationType?: ObservationType;
163 /** Source event time (ms epoch) — null when the source does not provide one. */ 166 /** Source event time (ms epoch) — null when the source does not provide one. */
164 sourceTimestamp: number | null; 167 sourceTimestamp: number | null;
165 timestampTrust: TimestampTrust; 168 timestampTrust: TimestampTrust;
@@ -208,7 +211,10 @@ export interface SourceContribution {
208 ageMs: number; 211 ageMs: number;
209 weight: number; 212 weight: number;
210 included: boolean; 213 included: boolean;
211− reason?: string; // why excluded (stale, outlier…) 214+ reason?: string; // why excluded (stale, outlier, not_comparable, temporal_mismatch, validation_only…)
215+ observationType: ObservationType;
216+ /** Difference to the canonical value in basis points (null when no canonical value). */
217+ deltaBps?: number | null;
212 realtimeStatus: RealtimeStatus; 218 realtimeStatus: RealtimeStatus;
213 rightsStatus: RightsStatus; 219 rightsStatus: RightsStatus;
214 } 220 }
@@ -227,8 +233,16 @@ export interface CanonicalQuote {
227 bid: number | null; 233 bid: number | null;
228 ask: number | null; 234 ask: number | null;
229 currency: string | null; 235 currency: string | null;
230− /** Number of independent source families supporting `price`. */ 236+ /** Number of independent source families supporting `price` (proxies excluded). */
231 sourceCount: number; 237 sourceCount: number;
238+ /** Fresh observations of any kind seen for the price field (included or not). */
239+ observationCount: number;
240+ /** Proxy/derived confirmations (stablecoin markets, crosses) that agree with the canonical value. */
241+ proxyCount: number;
242+ /** Restricted-rights sources used for validation only that agree with the canonical value. */
243+ validatorCount: number;
244+ /** Comparability class of the canonical value. */
245+ comparability: "LIVE" | "FIX" | "EOD" | null;
232 dispersionBps: number | null; 246 dispersionBps: number | null;
233 confidence: number; 247 confidence: number;
234 /** Age of the newest included observation at computation time. */ 248 /** Age of the newest included observation at computation time. */
235 249
Menu

Theme