SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%

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 11 days ago (Sep 13, 2026) parent e4eb788

60 changed files +3,739 −247

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