Source Mesh v2: observation types & comparability classes, validator/proxy roles, coverage engine + expansion queue, statistical lineage, 8 new connectors (Bitstamp, Gemini, Bitfinex, Bybit, Gate, Crypto.com, KuCoin, Nasdaq validator), live keyless FX (Kraken fiat pairs, stablecoin proxies)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
60 changed files +3,739 −247
modified
apps/api/src/api/public-view.ts
+4 −0
@@ -48,6 +48,10 @@ export function publicQuote(q: CanonicalQuote | undefined, inst?: Instrument, ma | |||||
| 48 | ask: v(q.ask), | 48 | ask: v(q.ask), | ||
| 49 | currency: q.currency, | 49 | currency: q.currency, | ||
| 50 | source_count: q.sourceCount, | 50 | source_count: q.sourceCount, | ||
| 51 | + observation_count: q.observationCount, | ||||
| 52 | + proxy_count: q.proxyCount, | ||||
| 53 | + validator_count: q.validatorCount, | ||||
| 54 | + comparability: q.comparability, | ||||
| 51 | dispersion_bps: q.dispersionBps, | 55 | dispersion_bps: q.dispersionBps, | ||
| 52 | confidence: q.confidence, | 56 | confidence: q.confidence, | ||
| 53 | freshness_ms: q.freshnessMs == null ? null : q.freshnessMs + age, | 57 | freshness_ms: q.freshnessMs == null ? null : q.freshnessMs + age, | ||
modified
apps/api/src/api/routes/admin.ts
+3 −0
@@ -10,6 +10,7 @@ import { observationWriter } from "../../core/observations.js"; | |||||
| 10 | import { assertPublicUrl, redactObject } from "@market-atlas/connector-sdk"; | 10 | import { assertPublicUrl, redactObject } from "@market-atlas/connector-sdk"; | ||
| 11 | import { ApiError, envelope, notFound } from "../server.js"; | 11 | import { ApiError, envelope, notFound } from "../server.js"; | ||
| 12 | import { runDiscovery } from "../../discovery/probe.js"; | 12 | import { runDiscovery } from "../../discovery/probe.js"; | ||
| 13 | +import { lineage } from "../../core/lineage.js"; | ||||
| 13 | 14 | ||||
| 14 | function requireAdmin(req: FastifyRequest) { | 15 | function requireAdmin(req: FastifyRequest) { | ||
| 15 | const token = req.headers["x-ma-admin-token"]; | 16 | const token = req.headers["x-ma-admin-token"]; | ||
@@ -128,6 +129,8 @@ export async function registerAdminRoutes(app: FastifyInstance) { | |||||
| 128 | } | 129 | } | ||
| 129 | }); | 130 | }); | ||
| 130 | 131 | ||||
| 132 | + app.get("/v1/admin/lineage", async () => envelope(lineage.pairs())); | ||||
| 133 | + | ||||
| 131 | app.get("/v1/admin/divergence", async () => { | 134 | app.get("/v1/admin/divergence", async () => { | ||
| 132 | const r = await pool.query("select id, ts, instrument_ids, title, data from market_events where type = 'SOURCE_DIVERGENCE' order by ts desc limit 100"); | 135 | const r = await pool.query("select id, ts, instrument_ids, title, data from market_events where type = 'SOURCE_DIVERGENCE' order by ts desc limit 100"); | ||
| 133 | return envelope(r.rows); | 136 | return envelope(r.rows); | ||
modified
apps/api/src/api/routes/markets.ts
+23 −1
@@ -1,6 +1,6 @@ | |||||
| 1 | import type { FastifyInstance } from "fastify"; | 1 | import type { FastifyInstance } from "fastify"; | ||
| 2 | import { z } from "zod"; | 2 | import { z } from "zod"; | ||
| 3 | −import { PUBLICLY_REDISTRIBUTABLE, type AssetClass, type Instrument } from "@market-atlas/market-model"; | 3 | +import { ASSET_CLASSES as ASSET_CLASSES_LIST, PUBLICLY_REDISTRIBUTABLE, type AssetClass, type Instrument } from "@market-atlas/market-model"; | ||
| 4 | import { pool } from "../../db/pool.js"; | 4 | import { pool } from "../../db/pool.js"; | ||
| 5 | import { calendar } from "../../core/calendar.js"; | 5 | import { calendar } from "../../core/calendar.js"; | ||
| 6 | import { instruments } from "../../core/instruments.js"; | 6 | import { instruments } from "../../core/instruments.js"; | ||
@@ -8,6 +8,9 @@ import { quoteStore } from "../../core/quotes.js"; | |||||
| 8 | import { badRequest, envelope, notFound } from "../server.js"; | 8 | import { badRequest, envelope, notFound } from "../server.js"; | ||
| 9 | import { publicInstrument, publicQuote } from "../public-view.js"; | 9 | import { publicInstrument, publicQuote } from "../public-view.js"; | ||
| 10 | import { rowToEvent, round } from "./public.js"; | 10 | import { rowToEvent, round } from "./public.js"; | ||
| 11 | +import { coverageAll, coverageOf, coverageSummary, expansionQueue } from "../../core/coverage.js"; | ||||
| 12 | +import { lineage } from "../../core/lineage.js"; | ||||
| 13 | +import { resolveInstrument } from "./public.js"; | ||||
| 11 | 14 | ||||
| 12 | const quoted = (pred: (i: Instrument) => boolean) => | 15 | const quoted = (pred: (i: Instrument) => boolean) => | ||
| 13 | instruments | 16 | instruments | ||
@@ -26,6 +29,25 @@ function featured(cls: AssetClass, n: number) { | |||||
| 26 | } | 29 | } | ||
| 27 | 30 | ||||
| 28 | export async function registerMarketRoutes(app: FastifyInstance) { | 31 | export async function registerMarketRoutes(app: FastifyInstance) { | ||
| 32 | + /** Source Coverage Engine: redundancy histogram, tier attainment, expansion queue. */ | ||||
| 33 | + app.get("/v1/coverage", async (req) => { | ||||
| 34 | + const p = z.object({ limit: z.coerce.number().int().min(1).max(500).default(50), asset_class: z.enum(ASSET_CLASSES_LIST).optional(), tier: z.enum(["A", "B", "C", "D"]).optional() }).parse(req.query); | ||||
| 35 | + let list = coverageAll(); | ||||
| 36 | + if (p.asset_class) list = list.filter((c) => c.assetClass === p.asset_class); | ||||
| 37 | + if (p.tier) list = list.filter((c) => c.tier === p.tier); | ||||
| 38 | + return envelope({ | ||||
| 39 | + summary: coverageSummary(list), | ||||
| 40 | + targets: { A: "≥3 independent families, 5 observations (majors)", B: "≥2 families, 3 observations (liquid)", C: "1 family, 2 observations (long tail)", D: "official single source acceptable (reference rates)" }, | ||||
| 41 | + queue: expansionQueue(list, p.limit), | ||||
| 42 | + lineage: lineage.pairs().filter((x) => x.likelySharedUpstream), | ||||
| 43 | + }); | ||||
| 44 | + }); | ||||
| 45 | + app.get<{ Params: { id: string } }>("/v1/coverage/:id", async (req) => { | ||||
| 46 | + const inst = resolveInstrument(req.params.id); | ||||
| 47 | + if (!inst) throw notFound("instrument"); | ||||
| 48 | + return envelope(coverageOf(inst, quoteStore.get(inst.id))); | ||||
| 49 | + }); | ||||
| 50 | + | ||||
| 29 | /** Global market overview for the homepage and /markets. */ | 51 | /** Global market overview for the homepage and /markets. */ | ||
| 30 | app.get("/v1/markets", async () => { | 52 | app.get("/v1/markets", async () => { | ||
| 31 | const exchanges = calendar.list().map((e) => ({ ...e, status: calendar.status(e.id) })); | 53 | const exchanges = calendar.list().map((e) => ({ ...e, status: calendar.status(e.id) })); | ||
modified
apps/api/src/api/routes/public.ts
+14 −1
@@ -13,6 +13,8 @@ import { pipeline } from "../../core/pipeline.js"; | |||||
| 13 | import { badRequest, envelope, notFound } from "../server.js"; | 13 | import { badRequest, envelope, notFound } from "../server.js"; | ||
| 14 | import { publicInstrument, publicQuote } from "../public-view.js"; | 14 | import { publicInstrument, publicQuote } from "../public-view.js"; | ||
| 15 | import { SOURCES } from "@market-atlas/connectors"; | 15 | import { SOURCES } from "@market-atlas/connectors"; | ||
| 16 | +import { coverageOf, coverageAll, coverageSummary } from "../../core/coverage.js"; | ||||
| 17 | +import { lineage } from "../../core/lineage.js"; | ||||
| 16 | 18 | ||||
| 17 | const q = <T extends z.ZodTypeAny>(schema: T, input: unknown): z.infer<T> => { | 19 | const q = <T extends z.ZodTypeAny>(schema: T, input: unknown): z.infer<T> => { | ||
| 18 | const r = schema.safeParse(input); | 20 | const r = schema.safeParse(input); | ||
@@ -111,6 +113,7 @@ export async function registerPublicRoutes(app: FastifyInstance) { | |||||
| 111 | events_per_min: round(telemetry.rate("events_total") * 60, 2), | 113 | events_per_min: round(telemetry.rate("events_total") * 60, 2), | ||
| 112 | filings_24h: filings.rows[0]?.n ?? 0, | 114 | filings_24h: filings.rows[0]?.n ?? 0, | ||
| 113 | median_freshness_ms: median(quoted.filter((x) => x.realtimeStatus === "REALTIME").map((x) => (x.freshnessMs ?? 0) + (Date.now() - x.updatedAt))), | 115 | median_freshness_ms: median(quoted.filter((x) => x.realtimeStatus === "REALTIME").map((x) => (x.freshnessMs ?? 0) + (Date.now() - x.updatedAt))), | ||
| 116 | + coverage: coverageSummary(coverageAll()), | ||||
| 114 | stream_clients: telemetry.counter("stream_clients_connected") - telemetry.counter("stream_clients_disconnected"), | 117 | stream_clients: telemetry.counter("stream_clients_connected") - telemetry.counter("stream_clients_disconnected"), | ||
| 115 | uptime_s: Math.round(process.uptime()), | 118 | uptime_s: Math.round(process.uptime()), | ||
| 116 | }); | 119 | }); | ||
@@ -205,12 +208,18 @@ export async function registerPublicRoutes(app: FastifyInstance) { | |||||
| 205 | confidence: quote.confidence, | 208 | confidence: quote.confidence, | ||
| 206 | dispersion_bps: quote.dispersionBps, | 209 | dispersion_bps: quote.dispersionBps, | ||
| 207 | independent_sources: quote.sourceCount, | 210 | independent_sources: quote.sourceCount, | ||
| 211 | + observations: quote.observationCount, | ||||
| 212 | + proxy_confirmations: quote.proxyCount, | ||||
| 213 | + validator_confirmations: quote.validatorCount, | ||||
| 214 | + comparability: quote.comparability, | ||||
| 215 | + coverage: coverageOf(inst, quote), | ||||
| 216 | + shared_upstream_pairs: lineage.pairs().filter((p) => p.likelySharedUpstream && quote.contributions.some((c) => c.sourceId === p.sourceA || c.sourceId === p.sourceB)), | ||||
| 208 | freshness_ms: quote.freshnessMs, | 217 | freshness_ms: quote.freshnessMs, | ||
| 209 | realtime_status: quote.realtimeStatus, | 218 | realtime_status: quote.realtimeStatus, | ||
| 210 | rights_status: quote.rightsStatus, | 219 | rights_status: quote.rightsStatus, | ||
| 211 | consensus_version: quote.consensusVersion, | 220 | consensus_version: quote.consensusVersion, | ||
| 212 | computed_at: new Date(quote.updatedAt).toISOString(), | 221 | computed_at: new Date(quote.updatedAt).toISOString(), | ||
| 213 | − method: "weighted median over fresh observations; one vote per upstream source family; outliers > 2% from the median excluded; weights = source reliability × timestamp quality × real-time class × freshness decay", | 222 | + method: "comparable observations only (same class: live / official fix / end-of-day, close in time); weighted median over fresh real-market observations, one vote per upstream source family (declared or statistically inferred), liquidity-aware for crypto, official sources dominate for rates; outliers > 2% excluded; stablecoin/derived proxies and restricted-rights sources confirm but never set the value", | ||
| 214 | contributions: quote.contributions.map((c) => ({ | 223 | contributions: quote.contributions.map((c) => ({ | ||
| 215 | source_id: c.sourceId, | 224 | source_id: c.sourceId, | ||
| 216 | source: sourceMeta[c.sourceId] ?? null, | 225 | source: sourceMeta[c.sourceId] ?? null, | ||
@@ -222,6 +231,8 @@ export async function registerPublicRoutes(app: FastifyInstance) { | |||||
| 222 | weight: c.weight, | 231 | weight: c.weight, | ||
| 223 | included: c.included, | 232 | included: c.included, | ||
| 224 | reason: c.reason ?? null, | 233 | reason: c.reason ?? null, | ||
| 234 | + observation_type: c.observationType, | ||||
| 235 | + delta_bps: c.deltaBps ?? null, | ||||
| 225 | realtime_status: c.realtimeStatus, | 236 | realtime_status: c.realtimeStatus, | ||
| 226 | rights_status: c.rightsStatus, | 237 | rights_status: c.rightsStatus, | ||
| 227 | reliability_score: health.sourceReliability(c.sourceId), | 238 | reliability_score: health.sourceReliability(c.sourceId), | ||
@@ -361,6 +372,8 @@ export async function registerPublicRoutes(app: FastifyInstance) { | |||||
| 361 | last_observation: last ? new Date(last).toISOString() : null, | 372 | last_observation: last ? new Date(last).toISOString() : null, | ||
| 362 | reliability_score: snaps.map((x) => x.reliabilityScore).filter((x): x is number => x != null).sort((a, b) => b - a)[0] ?? null, | 373 | reliability_score: snaps.map((x) => x.reliabilityScore).filter((x): x is number => x != null).sort((a, b) => b - a)[0] ?? null, | ||
| 363 | asset_classes: [...new Set(conns.flatMap((c) => c.def.metadata.assetClasses))], | 374 | asset_classes: [...new Set(conns.flatMap((c) => c.def.metadata.assetClasses))], | ||
| 375 | + likely_shared_upstream_with: lineage.pairs().filter((p) => p.likelySharedUpstream && (p.sourceA === s.id || p.sourceB === s.id)).map((p) => (p.sourceA === s.id ? p.sourceB : p.sourceA)), | ||||
| 376 | + role: conns.length && !PUBLICLY_REDISTRIBUTABLE.has(s.rights_status) ? "validator" : "contributor", | ||||
| 364 | description: conns[0]?.def.metadata.description ?? null, | 377 | description: conns[0]?.def.metadata.description ?? null, | ||
| 365 | rights_notes: conns[0]?.def.metadata.rightsNotes ?? null, | 378 | rights_notes: conns[0]?.def.metadata.rightsNotes ?? null, | ||
| 366 | terms_url: conns[0]?.def.metadata.termsUrl ?? null, | 379 | terms_url: conns[0]?.def.metadata.termsUrl ?? null, | ||
modified
apps/api/src/core/consensus.test.ts
+56 −1
@@ -103,7 +103,7 @@ describe("consensus engine", () => { | |||||
| 103 | const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!; | 103 | const q = e.compute("crypto_btc_usd", "BTC-USD", NOW)!; | ||
| 104 | expect(q.price).toBe(100); | 104 | expect(q.price).toBe(100); | ||
| 105 | expect(q.realtimeStatus).toBe("REALTIME"); | 105 | expect(q.realtimeStatus).toBe("REALTIME"); | ||
| 106 | − expect(q.contributions.find((c) => c.sourceId === "eod")).toMatchObject({ included: false, reason: "superseded_by_live" }); | 106 | + expect(q.contributions.find((c) => c.sourceId === "eod")).toMatchObject({ included: false, reason: "not_comparable" }); | ||
| 107 | }); | 107 | }); | ||
| 108 | it("market closed / everything stale → STALE status with last known value and zero confidence", () => { | 108 | it("market closed / everything stale → STALE status with last known value and zero confidence", () => { | ||
| 109 | const e = engine(); | 109 | const e = engine(); | ||
@@ -130,3 +130,58 @@ describe("consensus engine", () => { | |||||
| 130 | expect(weightedMedian([{ v: 1, w: 10 }, { v: 2, w: 1 }, { v: 100, w: 1 }])).toBe(1); | 130 | expect(weightedMedian([{ v: 1, w: 10 }, { v: 2, w: 1 }, { v: 100, w: 1 }])).toBe(1); | ||
| 131 | }); | 131 | }); | ||
| 132 | }); | 132 | }); | ||
| 133 | + | ||||
| 134 | +describe("comparability, roles and coverage inputs", () => { | ||||
| 135 | + const obsT = (source: string, value: number, ageMs: number, type: Observation["observationType"], rt: Observation["realtimeStatus"], rights: Observation["rightsStatus"] = "PUBLIC_ATTRIBUTED"): Observation => ({ | ||||
| 136 | + ...obs(source, value, ageMs, "LAST_PRICE", rt, "SOURCE"), | ||||
| 137 | + observationType: type, | ||||
| 138 | + rightsStatus: rights, | ||||
| 139 | + observationId: `${source}-${type}-${value}`, | ||||
| 140 | + }); | ||||
| 141 | + it("an official fixing is never compared to a live market value (no false divergence)", () => { | ||||
| 142 | + const e = engine(); | ||||
| 143 | + e.ingest(obsT("a", 1.1725, 500, "TRADE", "REALTIME")); // Kraken EUR/USD live | ||||
| 144 | + e.ingest(obsT("eod", 1.1592, 20 * 3_600_000, "OFFICIAL_FIX", "END_OF_DAY", "OFFICIAL_OPEN_DATA")); // ECB fixing yesterday | ||||
| 145 | + const q = e.compute("crypto_btc_usd", "EURUSD", NOW)!; | ||||
| 146 | + expect(q.price).toBe(1.1725); | ||||
| 147 | + expect(q.comparability).toBe("LIVE"); | ||||
| 148 | + expect(q.dispersionBps).toBe(0); | ||||
| 149 | + expect(q.contributions.find((c) => c.sourceId === "eod")).toMatchObject({ included: false, reason: "not_comparable" }); | ||||
| 150 | + }); | ||||
| 151 | + it("official fixing is canonical when no real live market exists; stablecoin proxies alone are INDICATIVE", () => { | ||||
| 152 | + const e = engine(); | ||||
| 153 | + e.ingest(obsT("eod", 1.1592, 20 * 3_600_000, "OFFICIAL_FIX", "END_OF_DAY", "OFFICIAL_OPEN_DATA")); | ||||
| 154 | + e.ingest(obsT("b", 1.1601, 500, "STABLECOIN_PROXY", "REALTIME")); | ||||
| 155 | + let q = e.compute("crypto_btc_usd", "EURUSD", NOW)!; | ||||
| 156 | + expect(q.comparability).toBe("FIX"); | ||||
| 157 | + expect(q.price).toBe(1.1592); | ||||
| 158 | + const e2 = engine(); | ||||
| 159 | + e2.ingest(obsT("b", 1.1601, 500, "STABLECOIN_PROXY", "REALTIME")); | ||||
| 160 | + q = e2.compute("crypto_btc_usd", "EURUSD", NOW)!; | ||||
| 161 | + expect(q.realtimeStatus).toBe("INDICATIVE"); | ||||
| 162 | + expect(q.sourceCount).toBe(0); // proxies are not independent families | ||||
| 163 | + expect(q.price).toBe(1.1601); | ||||
| 164 | + }); | ||||
| 165 | + it("proxies confirm a real market without setting the price; validators never vote", () => { | ||||
| 166 | + const e = engine(); | ||||
| 167 | + e.ingest(obsT("a", 100, 300, "TRADE", "REALTIME")); | ||||
| 168 | + e.ingest(obsT("b", 100.02, 300, "STABLECOIN_PROXY", "REALTIME")); | ||||
| 169 | + e.ingest(obsT("c", 100.01, 300, "TRADE", "DELAYED", "PUBLIC_RESTRICTED_REDISTRIBUTION")); | ||||
| 170 | + const q = e.compute("crypto_btc_usd", "EURUSD", NOW)!; | ||||
| 171 | + expect(q.price).toBe(100); | ||||
| 172 | + expect(q.sourceCount).toBe(1); | ||||
| 173 | + expect(q.proxyCount).toBe(1); | ||||
| 174 | + expect(q.validatorCount).toBe(1); | ||||
| 175 | + expect(q.contributions.find((c) => c.sourceId === "c")).toMatchObject({ included: false, reason: "validation_only" }); | ||||
| 176 | + expect(q.contributions.find((c) => c.sourceId === "c")!.deltaBps).toBeCloseTo(1, 0); | ||||
| 177 | + expect(q.rightsStatus).toBe("PUBLIC_ATTRIBUTED"); // restricted validator does not taint the canonical value | ||||
| 178 | + }); | ||||
| 179 | + it("same-class values far apart in time are temporal mismatches, not divergence", () => { | ||||
| 180 | + const e = engine(); | ||||
| 181 | + e.ingest(obsT("h", 91.01, 8 * 86_400_000, "EOD_CLOSE", "END_OF_DAY", "LICENSED")); // last week's close | ||||
| 182 | + e.ingest(obsT("g", 95.5, 20 * 3_600_000, "EOD_CLOSE", "END_OF_DAY", "LICENSED")); // yesterday's close | ||||
| 183 | + const q = e.compute("crypto_btc_usd", "CL=F", NOW)!; | ||||
| 184 | + expect(q.price).toBe(95.5); | ||||
| 185 | + expect(q.contributions.find((c) => c.sourceId === "h")).toMatchObject({ included: false, reason: "temporal_mismatch" }); | ||||
| 186 | + }); | ||||
| 187 | +}); | ||||
modified
apps/api/src/core/consensus.ts
+173 −85
@@ -1,5 +1,5 @@ | |||||
| 1 | −import type { CanonicalQuote, Observation, RealtimeStatus, RightsStatus, SourceContribution } from "@market-atlas/market-model"; | 1 | +import type { AssetClass, CanonicalQuote, ComparabilityClass, Observation, ObservationType, RealtimeStatus, RightsStatus, SourceContribution } from "@market-atlas/market-model"; | ||
| 2 | −import { CANONICAL_VERSIONS, PUBLICLY_REDISTRIBUTABLE } from "@market-atlas/market-model"; | 2 | +import { CANONICAL_VERSIONS, COMPARABILITY, PROXY_TYPES, PUBLICLY_REDISTRIBUTABLE } from "@market-atlas/market-model"; | ||
| 3 | import { config } from "../config.js"; | 3 | import { config } from "../config.js"; | ||
| 4 | 4 | ||||
| 5 | export interface SourceProfile { | 5 | export interface SourceProfile { | ||
@@ -13,7 +13,7 @@ interface Latest { | |||||
| 13 | obs: Observation; | 13 | obs: Observation; | ||
| 14 | } | 14 | } | ||
| 15 | 15 | ||||
| 16 | −/** Fresh window per realtime class: realtime ticks age out fast, EOD values live a day+. */ | 16 | +/** Fresh window per realtime class: realtime ticks age out fast, EOD values live for days. */ | ||
| 17 | const FRESH_WINDOW_MS: Record<RealtimeStatus, number> = { | 17 | const FRESH_WINDOW_MS: Record<RealtimeStatus, number> = { | ||
| 18 | REALTIME: config.consensusFreshWindowMs, | 18 | REALTIME: config.consensusFreshWindowMs, | ||
| 19 | DELAYED: 30 * 60_000, | 19 | DELAYED: 30 * 60_000, | ||
@@ -24,23 +24,54 @@ const FRESH_WINDOW_MS: Record<RealtimeStatus, number> = { | |||||
| 24 | }; | 24 | }; | ||
| 25 | 25 | ||||
| 26 | const REALTIME_RANK: Record<RealtimeStatus, number> = { REALTIME: 4, DELAYED: 3, INDICATIVE: 2, END_OF_DAY: 1, UNKNOWN: 0, STALE: -1 }; | 26 | const REALTIME_RANK: Record<RealtimeStatus, number> = { REALTIME: 4, DELAYED: 3, INDICATIVE: 2, END_OF_DAY: 1, UNKNOWN: 0, STALE: -1 }; | ||
| 27 | +/** Same-class observations must be close in time to be compared: live within 2 minutes, fixings/closes on the same UTC day. */ | ||||
| 28 | +const LIVE_TOLERANCE_MS = 120_000; | ||||
| 29 | + | ||||
| 30 | +/** Infer the observation type when a connector did not declare one. */ | ||||
| 31 | +export function inferObservationType(o: Pick<Observation, "realtimeStatus" | "observationType" | "field">): ObservationType { | ||||
| 32 | + if (o.observationType) return o.observationType; | ||||
| 33 | + if (o.field === "PREVIOUS_CLOSE") return "EOD_CLOSE"; | ||||
| 34 | + switch (o.realtimeStatus) { | ||||
| 35 | + case "END_OF_DAY": | ||||
| 36 | + return "EOD_CLOSE"; | ||||
| 37 | + case "INDICATIVE": | ||||
| 38 | + return "INDICATIVE"; | ||||
| 39 | + default: | ||||
| 40 | + return "TRADE"; | ||||
| 41 | + } | ||||
| 42 | +} | ||||
| 43 | + | ||||
| 44 | +interface Candidate { | ||||
| 45 | + value: number; | ||||
| 46 | + weight: number; | ||||
| 47 | + family: string; | ||||
| 48 | + obs: Observation; | ||||
| 49 | + type: ObservationType; | ||||
| 50 | + proxy: boolean; | ||||
| 51 | + validator: boolean; | ||||
| 52 | + contrib: SourceContribution; | ||||
| 53 | +} | ||||
| 27 | 54 | ||||
| 28 | /** | 55 | /** | ||
| 29 | − * Per-instrument consensus. Keeps the latest observation of each (source, field), computes a | 56 | + * Per-instrument consensus. Keeps the latest observation of each (source, field), keeps only | ||
| 30 | − * weighted median price over fresh observations, discounts sources sharing an upstream family, | 57 | + * *comparable* observations (same class — live / official fix / end-of-day — and close in time), | ||
| 31 | − * flags outliers, and exposes the full contribution list ("Why this price?"). | 58 | + * computes a weighted median (one vote per upstream family, liquidity-aware for crypto), rejects | ||
| 59 | + * outliers, uses restricted-rights sources and proxies as confirmations only, and exposes the full | ||||
| 60 | + * contribution list ("Why this price?"). | ||||
| 32 | */ | 61 | */ | ||
| 33 | export class ConsensusEngine { | 62 | export class ConsensusEngine { | ||
| 34 | private latest = new Map<string, Map<string, Latest>>(); // instrumentId -> `${sourceId}|${field}` -> latest | 63 | private latest = new Map<string, Map<string, Latest>>(); // instrumentId -> `${sourceId}|${field}` -> latest | ||
| 35 | private session = new Map<string, { high: number; low: number; startedAt: number }>(); | 64 | private session = new Map<string, { high: number; low: number; startedAt: number }>(); | ||
| 36 | 65 | ||||
| 37 | /** | 66 | /** | ||
| 38 | − * @param profiles source reliability/family lookup | 67 | + * @param profiles source reliability/family lookup | ||
| 39 | − * @param marketOpen true/false when the instrument's venue has a session calendar, null for continuous or unknown venues | 68 | + * @param marketOpen true/false when the instrument's venue has a session calendar, null for continuous or unknown venues | ||
| 69 | + * @param assetClassOf asset class of an instrument (liquidity weighting for crypto, official dominance for rates) | ||||
| 40 | */ | 70 | */ | ||
| 41 | constructor( | 71 | constructor( | ||
| 42 | private profiles: (sourceId: string) => SourceProfile, | 72 | private profiles: (sourceId: string) => SourceProfile, | ||
| 43 | private marketOpen: (instrumentId: string) => boolean | null = () => null, | 73 | private marketOpen: (instrumentId: string) => boolean | null = () => null, | ||
| 74 | + private assetClassOf: (instrumentId: string) => AssetClass | null = () => null, | ||||
| 44 | ) {} | 75 | ) {} | ||
| 45 | 76 | ||||
| 46 | ingest(o: Observation): void { | 77 | ingest(o: Observation): void { | ||
@@ -78,12 +109,20 @@ export class ConsensusEngine { | |||||
| 78 | return [...new Set([...m.keys()].map((k) => k.split("|")[0]!))]; | 109 | return [...new Set([...m.keys()].map((k) => k.split("|")[0]!))]; | ||
| 79 | } | 110 | } | ||
| 80 | 111 | ||||
| 112 | + /** Latest LAST_PRICE observation per source (for lineage inference and coverage). */ | ||||
| 113 | + latestPrices(instrumentId: string): Observation[] { | ||||
| 114 | + const m = this.latest.get(instrumentId); | ||||
| 115 | + if (!m) return []; | ||||
| 116 | + return [...m.entries()].filter(([k]) => k.endsWith("|LAST_PRICE")).map(([, l]) => l.obs); | ||||
| 117 | + } | ||||
| 118 | + | ||||
| 81 | compute(instrumentId: string, symbol: string, now = Date.now()): CanonicalQuote | null { | 119 | compute(instrumentId: string, symbol: string, now = Date.now()): CanonicalQuote | null { | ||
| 82 | const m = this.latest.get(instrumentId); | 120 | const m = this.latest.get(instrumentId); | ||
| 83 | if (!m) return null; | 121 | if (!m) return null; | ||
| 84 | const closed = this.marketOpen(instrumentId) === false; | 122 | const closed = this.marketOpen(instrumentId) === false; | ||
| 85 | − const price = this.aggregate(m, "LAST_PRICE", now, closed); | 123 | + const assetClass = this.assetClassOf(instrumentId); | ||
| 86 | − const pick = (field: string) => this.aggregate(m, field, now, closed); | 124 | + const price = this.aggregate(m, "LAST_PRICE", now, closed, assetClass); | ||
| 125 | + const pick = (field: string) => this.aggregate(m, field, now, closed, assetClass); | ||||
| 87 | const open = pick("OPEN"); | 126 | const open = pick("OPEN"); | ||
| 88 | const high = pick("HIGH"); | 127 | const high = pick("HIGH"); | ||
| 89 | const low = pick("LOW"); | 128 | const low = pick("LOW"); | ||
@@ -104,7 +143,6 @@ export class ConsensusEngine { | |||||
| 104 | change = p - open.value; | 143 | change = p - open.value; | ||
| 105 | changePercent = (change / open.value) * 100; | 144 | changePercent = (change / open.value) * 100; | ||
| 106 | } | 145 | } | ||
| 107 | − // Session high/low derived by Market Atlas itself. | ||||
| 108 | let sess = this.session.get(instrumentId); | 146 | let sess = this.session.get(instrumentId); | ||
| 109 | if (p != null) { | 147 | if (p != null) { | ||
| 110 | if (!sess) { | 148 | if (!sess) { | ||
@@ -135,6 +173,10 @@ export class ConsensusEngine { | |||||
| 135 | ask: ask.value, | 173 | ask: ask.value, | ||
| 136 | currency, | 174 | currency, | ||
| 137 | sourceCount: price.independentFamilies, | 175 | sourceCount: price.independentFamilies, | ||
| 176 | + observationCount: price.observationCount, | ||||
| 177 | + proxyCount: price.proxyCount, | ||||
| 178 | + validatorCount: price.validatorCount, | ||||
| 179 | + comparability: price.comparability, | ||||
| 138 | dispersionBps: price.dispersionBps, | 180 | dispersionBps: price.dispersionBps, | ||
| 139 | confidence: price.confidence, | 181 | confidence: price.confidence, | ||
| 140 | freshnessMs: price.freshnessMs, | 182 | freshnessMs: price.freshnessMs, | ||
@@ -149,121 +191,167 @@ export class ConsensusEngine { | |||||
| 149 | }; | 191 | }; | ||
| 150 | } | 192 | } | ||
| 151 | 193 | ||||
| 152 | − private aggregate(m: Map<string, Latest>, field: string, now: number, marketClosed = false) { | 194 | + private aggregate(m: Map<string, Latest>, field: string, now: number, marketClosed: boolean, assetClass: AssetClass | null) { | ||
| 153 | const contributions: SourceContribution[] = []; | 195 | const contributions: SourceContribution[] = []; | ||
| 154 | − const candidates: Array<{ value: number; weight: number; family: string; obs: Observation }> = []; | 196 | + const fresh: Candidate[] = []; | ||
| 197 | + const empty = { | ||||
| 198 | + value: null as number | null, | ||||
| 199 | + confidence: 0, | ||||
| 200 | + dispersionBps: null as number | null, | ||||
| 201 | + independentFamilies: 0, | ||||
| 202 | + observationCount: 0, | ||||
| 203 | + proxyCount: 0, | ||||
| 204 | + validatorCount: 0, | ||||
| 205 | + comparability: null as ComparabilityClass | null, | ||||
| 206 | + freshnessMs: null as number | null, | ||||
| 207 | + realtimeStatus: "STALE" as RealtimeStatus, | ||||
| 208 | + sourceTimestamp: null as number | null, | ||||
| 209 | + contributions, | ||||
| 210 | + }; | ||||
| 211 | + | ||||
| 155 | for (const [key, l] of m) { | 212 | for (const [key, l] of m) { | ||
| 156 | if (!key.endsWith(`|${field}`)) continue; | 213 | if (!key.endsWith(`|${field}`)) continue; | ||
| 157 | const o = l.obs; | 214 | const o = l.obs; | ||
| 158 | const prof = this.profiles(o.sourceId); | 215 | const prof = this.profiles(o.sourceId); | ||
| 216 | + const type = inferObservationType(o); | ||||
| 159 | const eventTs = o.sourceTimestamp ?? o.receivedAt; | 217 | const eventTs = o.sourceTimestamp ?? o.receivedAt; | ||
| 160 | const ageMs = Math.max(0, now - eventTs); | 218 | const ageMs = Math.max(0, now - eventTs); | ||
| 161 | // While the venue is closed, the last session value is the truth for up to 4 days (long weekends). | 219 | // While the venue is closed, the last session value is the truth for up to 4 days (long weekends). | ||
| 162 | const window = marketClosed && (o.realtimeStatus === "REALTIME" || o.realtimeStatus === "DELAYED") ? 4 * 86_400_000 : (FRESH_WINDOW_MS[o.realtimeStatus] ?? 60_000); | 220 | const window = marketClosed && (o.realtimeStatus === "REALTIME" || o.realtimeStatus === "DELAYED") ? 4 * 86_400_000 : (FRESH_WINDOW_MS[o.realtimeStatus] ?? 60_000); | ||
| 163 | − const fresh = ageMs <= window; | 221 | + const isFresh = ageMs <= window; | ||
| 164 | const base = 0.35 + 0.65 * prof.reliability; | 222 | const base = 0.35 + 0.65 * prof.reliability; | ||
| 165 | const tsQuality = o.timestampTrust === "EXCHANGE" ? 1 : o.timestampTrust === "SOURCE" ? 0.9 : 0.75; | 223 | const tsQuality = o.timestampTrust === "EXCHANGE" ? 1 : o.timestampTrust === "SOURCE" ? 0.9 : 0.75; | ||
| 166 | const rtQuality = o.realtimeStatus === "REALTIME" ? 1 : o.realtimeStatus === "DELAYED" ? 0.6 : o.realtimeStatus === "INDICATIVE" ? 0.5 : 0.4; | 224 | const rtQuality = o.realtimeStatus === "REALTIME" ? 1 : o.realtimeStatus === "DELAYED" ? 0.6 : o.realtimeStatus === "INDICATIVE" ? 0.5 : 0.4; | ||
| 167 | − const official = prof.isOfficial ? 1.15 : 1; | 225 | + const official = prof.isOfficial ? (assetClass === "TREASURY" || assetClass === "INTEREST_RATE" || assetClass === "BOND" ? 1.6 : 1.15) : 1; | ||
| 168 | − const decay = fresh ? Math.exp(-ageMs / Math.max(1000, window)) : 0; | 226 | + const decay = isFresh ? Math.exp(-ageMs / Math.max(1000, window)) : 0; | ||
| 169 | const weight = base * tsQuality * rtQuality * official * (0.4 + 0.6 * decay) * (o.confidence ?? 1); | 227 | const weight = base * tsQuality * rtQuality * official * (0.4 + 0.6 * decay) * (o.confidence ?? 1); | ||
| 170 | − const c: SourceContribution = { | 228 | + const contrib: SourceContribution = { | ||
| 171 | sourceId: o.sourceId, | 229 | sourceId: o.sourceId, | ||
| 172 | connectorId: o.connectorId, | 230 | connectorId: o.connectorId, | ||
| 173 | value: o.value, | 231 | value: o.value, | ||
| 174 | sourceTimestamp: o.sourceTimestamp, | 232 | sourceTimestamp: o.sourceTimestamp, | ||
| 175 | receivedAt: o.receivedAt, | 233 | receivedAt: o.receivedAt, | ||
| 176 | ageMs, | 234 | ageMs, | ||
| 177 | − weight: fresh ? round(weight, 4) : 0, | 235 | + weight: 0, | ||
| 178 | − included: fresh, | 236 | + included: false, | ||
| 179 | − realtimeStatus: fresh ? o.realtimeStatus : "STALE", | 237 | + observationType: type, | ||
| 238 | + deltaBps: null, | ||||
| 239 | + realtimeStatus: isFresh ? o.realtimeStatus : "STALE", | ||||
| 180 | rightsStatus: o.rightsStatus, | 240 | rightsStatus: o.rightsStatus, | ||
| 181 | − ...(fresh ? {} : { reason: "stale" }), | 241 | + ...(isFresh ? {} : { reason: "stale" }), | ||
| 182 | }; | 242 | }; | ||
| 183 | − contributions.push(c); | 243 | + contributions.push(contrib); | ||
| 184 | − if (fresh) candidates.push({ value: o.value, weight, family: prof.family ?? o.sourceId, obs: o }); | 244 | + if (isFresh) fresh.push({ value: o.value, weight, family: prof.family ?? o.sourceId, obs: o, type, proxy: PROXY_TYPES.has(type), validator: !PUBLICLY_REDISTRIBUTABLE.has(o.rightsStatus), contrib }); | ||
| 185 | } | 245 | } | ||
| 186 | − if (!candidates.length) { | 246 | + if (!fresh.length) { | ||
| 187 | // Fall back to the most recent stale value so the UI can show "last known", clearly labelled STALE. | 247 | // Fall back to the most recent stale value so the UI can show "last known", clearly labelled STALE. | ||
| 188 | − const newest = contributions.sort((a, b) => a.ageMs - b.ageMs)[0]; | 248 | + const newest = [...contributions].sort((a, b) => a.ageMs - b.ageMs)[0]; | ||
| 189 | − return { | 249 | + return { ...empty, value: newest?.value ?? null, freshnessMs: newest?.ageMs ?? null, sourceTimestamp: newest?.sourceTimestamp ?? null, contributions: contributions.sort((a, b) => a.ageMs - b.ageMs) }; | ||
| 190 | − value: newest?.value ?? null, | ||||
| 191 | − confidence: 0, | ||||
| 192 | − dispersionBps: null, | ||||
| 193 | − independentFamilies: 0, | ||||
| 194 | − freshnessMs: newest?.ageMs ?? null, | ||||
| 195 | − realtimeStatus: "STALE" as RealtimeStatus, | ||||
| 196 | − sourceTimestamp: newest?.sourceTimestamp ?? null, | ||||
| 197 | − contributions, | ||||
| 198 | − }; | ||||
| 199 | } | 250 | } | ||
| 200 | − // Tiering: when live (REALTIME/DELAYED) observations exist, end-of-day/indicative values are superseded. | 251 | + | ||
| 201 | − const bestRank = Math.max(...candidates.map((c) => REALTIME_RANK[c.obs.realtimeStatus])); | 252 | + // 1. Comparability class: live values from real markets win, then official fixings, then end-of-day closes, | ||
| 202 | − if (bestRank >= 3) { | 253 | + // then live proxies alone (stablecoin markets over a weekend). Other classes are reported, never mixed. | ||
| 203 | − for (const c of candidates) { | 254 | + const cls = (c: Candidate) => COMPARABILITY[c.type]; | ||
| 204 | − if (REALTIME_RANK[c.obs.realtimeStatus] <= 2) { | 255 | + const hasRealLive = fresh.some((c) => cls(c) === "LIVE" && !c.proxy); | ||
| 205 | − const contrib = contributions.find((x) => x.sourceId === c.obs.sourceId && x.included); | 256 | + const chosen: ComparabilityClass = hasRealLive ? "LIVE" : fresh.some((c) => cls(c) === "FIX") ? "FIX" : fresh.some((c) => cls(c) === "EOD") ? "EOD" : "LIVE"; | ||
| 206 | − if (contrib) { | 257 | + let pool = fresh.filter((c) => cls(c) === chosen); | ||
| 207 | − contrib.included = false; | 258 | + for (const c of fresh) if (cls(c) !== chosen) exclude(c, "not_comparable"); | ||
| 208 | − contrib.reason = "superseded_by_live"; | 259 | + | ||
| 209 | − contrib.weight = 0; | 260 | + // 2. Temporal tolerance inside the class. | ||
| 210 | − } | 261 | + const tsOf = (c: Candidate) => c.obs.sourceTimestamp ?? c.obs.receivedAt; | ||
| 211 | − } | 262 | + const freshestTs = Math.max(...pool.map(tsOf)); | ||
| 263 | + pool = pool.filter((c) => { | ||||
| 264 | + const ok = chosen === "LIVE" ? freshestTs - tsOf(c) <= LIVE_TOLERANCE_MS : sameUtcDay(tsOf(c), freshestTs); | ||||
| 265 | + if (!ok) exclude(c, "temporal_mismatch"); | ||||
| 266 | + return ok; | ||||
| 267 | + }); | ||||
| 268 | + | ||||
| 269 | + // 3. Roles: restricted-rights sources validate only; proxies confirm with reduced weight when real sources exist. | ||||
| 270 | + const validators = pool.filter((c) => c.validator); | ||||
| 271 | + for (const c of validators) exclude(c, "validation_only"); | ||||
| 272 | + let voters = pool.filter((c) => !c.validator); | ||||
| 273 | + const realVoters = voters.filter((c) => !c.proxy); | ||||
| 274 | + const proxies = voters.filter((c) => c.proxy); | ||||
| 275 | + if (realVoters.length) for (const c of proxies) c.weight *= 0.35; | ||||
| 276 | + | ||||
| 277 | + // 4. One vote per upstream family (the freshest keeps its weight, others are discounted). | ||||
| 278 | + const seenFamily = new Set<string>(); | ||||
| 279 | + for (const c of [...voters].sort((a, b) => tsOf(b) - tsOf(a))) { | ||||
| 280 | + if (seenFamily.has(c.family)) c.weight *= 0.25; | ||||
| 281 | + else seenFamily.add(c.family); | ||||
| 282 | + } | ||||
| 283 | + // 5. Crypto: liquidity-aware — a venue's weight scales with its share of the 24 h volume among voters. | ||||
| 284 | + if (assetClass === "CRYPTO" && voters.length > 1) { | ||||
| 285 | + const vols = new Map<string, number>(); | ||||
| 286 | + for (const c of voters) { | ||||
| 287 | + const v = m.get(`${c.obs.sourceId}|VOLUME`)?.obs.value; | ||||
| 288 | + if (v != null && v > 0) vols.set(c.obs.sourceId, v * c.value); // notional | ||||
| 212 | } | 289 | } | ||
| 213 | − const live = candidates.filter((c) => REALTIME_RANK[c.obs.realtimeStatus] >= 3); | 290 | + const max = Math.max(0, ...vols.values()); | ||
| 214 | − candidates.length = 0; | 291 | + if (max > 0) for (const c of voters) c.weight *= 0.6 + 0.8 * ((vols.get(c.obs.sourceId) ?? 0) / max); | ||
| 215 | − candidates.push(...live); | ||||
| 216 | } | 292 | } | ||
| 217 | − // Family de-duplication: one vote per upstream family (the freshest), others get a small weight. | 293 | + | ||
| 218 | − const seenFamily = new Map<string, number>(); | 294 | + // 6. Outlier rejection (> 2 % from the weighted median of real voters, when at least 3 voters). | ||
| 219 | − for (const c of candidates) { | 295 | + const reference = realVoters.length ? realVoters : voters; | ||
| 220 | − const prior = seenFamily.get(c.family); | 296 | + let median = weightedMedian(reference.map((c) => ({ v: c.value, w: c.weight }))); | ||
| 221 | − if (prior == null) seenFamily.set(c.family, c.weight); | 297 | + if (voters.length >= 3 && median !== 0) { | ||
| 222 | − else c.weight *= 0.25; | 298 | + const kept = voters.filter((c) => Math.abs(c.value - median) / Math.abs(median) <= 0.02); | ||
| 299 | + for (const c of voters) if (!kept.includes(c)) exclude(c, "outlier"); | ||||
| 300 | + if (kept.length) voters = kept; | ||||
| 301 | + median = weightedMedian((kept.filter((c) => !c.proxy).length ? kept.filter((c) => !c.proxy) : kept).map((c) => ({ v: c.value, w: c.weight }))); | ||||
| 223 | } | 302 | } | ||
| 224 | − const median = weightedMedian(candidates.map((c) => ({ v: c.value, w: c.weight }))); | 303 | + const finalVoters = voters; | ||
| 225 | − // Outlier rejection: > 2% away from median with ≥ 3 candidates → excluded. | 304 | + for (const c of finalVoters) { | ||
| 226 | − let usable = candidates; | 305 | + c.contrib.included = true; | ||
| 227 | − if (candidates.length >= 3 && median !== 0) { | 306 | + c.contrib.weight = round(c.weight, 4); | ||
| 228 | − usable = candidates.filter((c) => Math.abs(c.value - median) / Math.abs(median) <= 0.02); | ||||
| 229 | − for (const c of candidates) { | ||||
| 230 | − if (!usable.includes(c)) { | ||||
| 231 | − const contrib = contributions.find((x) => x.sourceId === c.obs.sourceId && x.included); | ||||
| 232 | − if (contrib) { | ||||
| 233 | − contrib.included = false; | ||||
| 234 | − contrib.reason = "outlier"; | ||||
| 235 | − contrib.weight = 0; | ||||
| 236 | − } | ||||
| 237 | − } | ||||
| 238 | − } | ||||
| 239 | − if (!usable.length) usable = candidates; | ||||
| 240 | } | 307 | } | ||
| 241 | − const finalMedian = weightedMedian(usable.map((c) => ({ v: c.value, w: c.weight }))); | 308 | + // Deltas vs canonical for every fresh contribution (included or not). | ||
| 242 | − const values = usable.map((c) => c.value); | 309 | + for (const c of fresh) c.contrib.deltaBps = median ? round(((c.value - median) / Math.abs(median)) * 10_000, 2) : null; | ||
| 243 | − const spread = values.length > 1 ? (Math.max(...values) - Math.min(...values)) / Math.abs(finalMedian || 1) : 0; | 310 | + | ||
| 244 | − const dispersionBps = round(spread * 10_000, 2); | 311 | + const realIncluded = finalVoters.filter((c) => !c.proxy); | ||
| 245 | − const families = new Set(usable.map((c) => c.family)).size; | 312 | + const spreadBasis = realIncluded.length ? realIncluded : finalVoters; | ||
| 246 | − const newest = Math.min(...usable.map((c) => Math.max(0, now - (c.obs.sourceTimestamp ?? c.obs.receivedAt)))); | 313 | + const values = spreadBasis.map((c) => c.value); | ||
| 247 | − const bestRt = usable.map((c) => c.obs.realtimeStatus).sort((a, b) => REALTIME_RANK[b] - REALTIME_RANK[a])[0] ?? "UNKNOWN"; | 314 | + const spread = values.length > 1 ? (Math.max(...values) - Math.min(...values)) / Math.abs(median || 1) : 0; | ||
| 248 | − // Confidence: agreement (dispersion), redundancy (families), freshness. Never claims > 0.995. | 315 | + const families = new Set(realIncluded.map((c) => c.family)).size; | ||
| 316 | + const proxyCount = finalVoters.filter((c) => c.proxy && Math.abs(c.contrib.deltaBps ?? 1e9) <= 50).length; | ||||
| 317 | + const validatorCount = validators.filter((c) => Math.abs(c.contrib.deltaBps ?? 1e9) <= 25).length; | ||||
| 318 | + for (const c of validators) if (Math.abs(c.contrib.deltaBps ?? 0) > 50) c.contrib.reason = "validator_disagrees"; | ||||
| 319 | + const newest = Math.min(...finalVoters.map((c) => Math.max(0, now - tsOf(c)))); | ||||
| 320 | + let bestRt = finalVoters.map((c) => c.obs.realtimeStatus).sort((a, b) => REALTIME_RANK[b] - REALTIME_RANK[a])[0] ?? "UNKNOWN"; | ||||
| 321 | + if (chosen === "LIVE" && !realIncluded.length) bestRt = "INDICATIVE"; // proxies only | ||||
| 322 | + // Confidence: agreement (dispersion), redundancy (independent families + confirmations), freshness, reliability. Never claims > 0.995. | ||||
| 249 | const agreement = Math.max(0, 1 - Math.min(1, spread / 0.01)); // 1% spread → 0 | 323 | const agreement = Math.max(0, 1 - Math.min(1, spread / 0.01)); // 1% spread → 0 | ||
| 250 | − const redundancy = 1 - Math.exp(-families / 2); // 1 fam → 0.39, 3 → 0.78, 5 → 0.92 | 324 | + const redundancy = 1 - Math.exp(-(families + 0.5 * proxyCount + 0.5 * validatorCount) / 2); | ||
| 251 | const freshness = marketClosed ? 1 : Math.exp(-newest / 60_000); | 325 | const freshness = marketClosed ? 1 : Math.exp(-newest / 60_000); | ||
| 252 | − const avgReliability = usable.reduce((a, c) => a + this.profiles(c.obs.sourceId).reliability, 0) / usable.length; | 326 | + const avgReliability = finalVoters.reduce((a, c) => a + this.profiles(c.obs.sourceId).reliability, 0) / finalVoters.length; | ||
| 253 | const confidence = round(Math.min(0.995, 0.15 + 0.35 * agreement + 0.25 * redundancy + 0.1 * freshness + 0.15 * avgReliability), 3); | 327 | const confidence = round(Math.min(0.995, 0.15 + 0.35 * agreement + 0.25 * redundancy + 0.1 * freshness + 0.15 * avgReliability), 3); | ||
| 254 | return { | 328 | return { | ||
| 255 | − value: finalMedian, | 329 | + value: median, | ||
| 256 | confidence, | 330 | confidence, | ||
| 257 | − dispersionBps, | 331 | + dispersionBps: round(spread * 10_000, 2), | ||
| 258 | independentFamilies: families, | 332 | independentFamilies: families, | ||
| 333 | + observationCount: fresh.length, | ||||
| 334 | + proxyCount, | ||||
| 335 | + validatorCount, | ||||
| 336 | + comparability: chosen, | ||||
| 259 | freshnessMs: Math.round(newest), | 337 | freshnessMs: Math.round(newest), | ||
| 260 | realtimeStatus: bestRt, | 338 | realtimeStatus: bestRt, | ||
| 261 | − sourceTimestamp: usable.map((c) => c.obs.sourceTimestamp).filter((t): t is number => t != null).sort((a, b) => b - a)[0] ?? null, | 339 | + sourceTimestamp: finalVoters.map((c) => c.obs.sourceTimestamp).filter((t): t is number => t != null).sort((a, b) => b - a)[0] ?? null, | ||
| 262 | contributions: contributions.sort((a, b) => Number(b.included) - Number(a.included) || a.ageMs - b.ageMs), | 340 | contributions: contributions.sort((a, b) => Number(b.included) - Number(a.included) || a.ageMs - b.ageMs), | ||
| 263 | }; | 341 | }; | ||
| 264 | } | 342 | } | ||
| 265 | } | 343 | } | ||
| 266 | 344 | ||||
| 345 | +function exclude(c: Candidate, reason: string) { | ||||
| 346 | + c.contrib.included = false; | ||||
| 347 | + c.contrib.weight = 0; | ||||
| 348 | + if (!c.contrib.reason) c.contrib.reason = reason; | ||||
| 349 | +} | ||||
| 350 | + | ||||
| 351 | +function sameUtcDay(a: number, b: number): boolean { | ||||
| 352 | + return Math.floor(a / 86_400_000) === Math.floor(b / 86_400_000); | ||||
| 353 | +} | ||||
| 354 | + | ||||
| 267 | export function weightedMedian(items: Array<{ v: number; w: number }>): number { | 355 | export function weightedMedian(items: Array<{ v: number; w: number }>): number { | ||
| 268 | if (!items.length) return NaN; | 356 | if (!items.length) return NaN; | ||
| 269 | const sorted = [...items].sort((a, b) => a.v - b.v); | 357 | const sorted = [...items].sort((a, b) => a.v - b.v); | ||
added
apps/api/src/core/coverage.ts
+164 −0
@@ -0,0 +1,164 @@ | |||||
| 1 | +import type { AssetClass, CanonicalQuote, Instrument } from "@market-atlas/market-model"; | ||||
| 2 | +import { PUBLICLY_REDISTRIBUTABLE } from "@market-atlas/market-model"; | ||||
| 3 | +import { instruments } from "./instruments.js"; | ||||
| 4 | +import { quoteStore } from "./quotes.js"; | ||||
| 5 | + | ||||
| 6 | +/** | ||||
| 7 | + * Source Coverage Engine — "one instrument → many observers → one canonical market state". | ||||
| 8 | + * Every quoted instrument gets a redundancy target by tier and a coverage status; the gap list is the | ||||
| 9 | + * source-expansion queue. Counts are *independent families*, never raw connector counts. | ||||
| 10 | + */ | ||||
| 11 | +export type CoverageTier = "A" | "B" | "C" | "D"; | ||||
| 12 | +export type CoverageStatus = "COVERED" | "UNDERCOVERED" | "SINGLE_SOURCE" | "NO_DATA"; | ||||
| 13 | + | ||||
| 14 | +export interface CoverageTarget { | ||||
| 15 | + tier: CoverageTier; | ||||
| 16 | + families: number; // minimum independent families | ||||
| 17 | + observations: number; // fresh observations (any role) aimed for | ||||
| 18 | +} | ||||
| 19 | + | ||||
| 20 | +export interface InstrumentCoverage { | ||||
| 21 | + instrumentId: string; | ||||
| 22 | + symbol: string; | ||||
| 23 | + name: string; | ||||
| 24 | + assetClass: AssetClass; | ||||
| 25 | + tier: CoverageTier; | ||||
| 26 | + target: CoverageTarget; | ||||
| 27 | + families: number; | ||||
| 28 | + observations: number; | ||||
| 29 | + proxies: number; | ||||
| 30 | + validators: number; | ||||
| 31 | + liveSources: number; | ||||
| 32 | + delayedSources: number; | ||||
| 33 | + officialSources: number; | ||||
| 34 | + comparability: CanonicalQuote["comparability"]; | ||||
| 35 | + score: number; // 0..1 | ||||
| 36 | + status: CoverageStatus; | ||||
| 37 | + gap: number; // missing independent families to reach the target | ||||
| 38 | +} | ||||
| 39 | + | ||||
| 40 | +/** Tier-A majors: the instruments that must be observed through several independent paths. */ | ||||
| 41 | +const TIER_A_SYMBOLS = new Set([ | ||||
| 42 | + "AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "SPY", "QQQ", "SPX", "_SPX", "NDX", "_NDX", "DJI", "_DJI", "RUT", "_RUT", "VIX", "_VIX", | ||||
| 43 | + "BTC-USD", "ETH-USD", "BTC-USDT", "ETH-USDT", "SOL-USD", "XRP-USD", | ||||
| 44 | + "EURUSD", "USDJPY", "GBPUSD", "USDCAD", "USDCHF", "AUDUSD", | ||||
| 45 | + "US10Y", "US2Y", "US30Y", "US3M", "GC=F", "CL=F", | ||||
| 46 | +]); | ||||
| 47 | +const TIER_A_IDS = new Set(["crypto_btc_usd", "crypto_eth_usd", "crypto_btc_usdt", "crypto_eth_usdt", "fx_eur_usd", "fx_usd_jpy", "fx_gbp_usd", "fx_usd_cad", "index_us_spx", "index_us_ndx", "index_us_dji", "rate_us_us10y"]); | ||||
| 48 | + | ||||
| 49 | +export function tierOf(inst: Instrument): CoverageTier { | ||||
| 50 | + // Official single-source reference series (yields, policy rates): the official publisher is the truth — one source is acceptable. | ||||
| 51 | + if (inst.assetClass === "TREASURY" || inst.assetClass === "INTEREST_RATE" || inst.assetClass === "BOND") return "D"; | ||||
| 52 | + if (TIER_A_IDS.has(inst.id) || TIER_A_SYMBOLS.has(inst.symbol) || TIER_A_SYMBOLS.has(inst.symbol.toUpperCase())) return "A"; | ||||
| 53 | + if (inst.metadata?.featured) return "B"; | ||||
| 54 | + if (inst.assetClass === "CRYPTO" || inst.assetClass === "FOREX" || inst.assetClass === "INDEX" || inst.assetClass === "ETF") return "B"; | ||||
| 55 | + return "C"; | ||||
| 56 | +} | ||||
| 57 | + | ||||
| 58 | +export const TARGETS: Record<CoverageTier, CoverageTarget> = { | ||||
| 59 | + A: { tier: "A", families: 3, observations: 5 }, | ||||
| 60 | + B: { tier: "B", families: 2, observations: 3 }, | ||||
| 61 | + C: { tier: "C", families: 1, observations: 2 }, | ||||
| 62 | + D: { tier: "D", families: 1, observations: 1 }, | ||||
| 63 | +}; | ||||
| 64 | + | ||||
| 65 | +export function coverageOf(inst: Instrument, q: CanonicalQuote | undefined): InstrumentCoverage { | ||||
| 66 | + const tier = tierOf(inst); | ||||
| 67 | + const target = TARGETS[tier]; | ||||
| 68 | + const fresh = (q?.contributions ?? []).filter((c) => c.reason !== "stale"); | ||||
| 69 | + const included = fresh.filter((c) => c.included); | ||||
| 70 | + const families = q?.sourceCount ?? 0; | ||||
| 71 | + const observations = q?.observationCount ?? fresh.length; | ||||
| 72 | + const live = included.filter((c) => c.realtimeStatus === "REALTIME").length; | ||||
| 73 | + const delayed = included.filter((c) => c.realtimeStatus === "DELAYED").length; | ||||
| 74 | + const official = fresh.filter((c) => c.rightsStatus === "OFFICIAL_OPEN_DATA").length; | ||||
| 75 | + const proxies = q?.proxyCount ?? 0; | ||||
| 76 | + const validators = q?.validatorCount ?? 0; | ||||
| 77 | + const hasPrice = q?.price != null && PUBLICLY_REDISTRIBUTABLE.has(q.rightsStatus); | ||||
| 78 | + const famScore = Math.min(1, families / target.families); | ||||
| 79 | + const obsScore = Math.min(1, (observations + 0.5 * proxies + 0.5 * validators) / target.observations); | ||||
| 80 | + const score = hasPrice ? Math.round((0.65 * famScore + 0.35 * obsScore) * 100) / 100 : 0; | ||||
| 81 | + const status: CoverageStatus = !hasPrice ? "NO_DATA" : families >= target.families && observations + proxies + validators >= Math.min(target.observations, target.families) ? "COVERED" : families <= 1 ? "SINGLE_SOURCE" : "UNDERCOVERED"; | ||||
| 82 | + return { | ||||
| 83 | + instrumentId: inst.id, | ||||
| 84 | + symbol: inst.symbol, | ||||
| 85 | + name: inst.name, | ||||
| 86 | + assetClass: inst.assetClass, | ||||
| 87 | + tier, | ||||
| 88 | + target, | ||||
| 89 | + families, | ||||
| 90 | + observations, | ||||
| 91 | + proxies, | ||||
| 92 | + validators, | ||||
| 93 | + liveSources: live, | ||||
| 94 | + delayedSources: delayed, | ||||
| 95 | + officialSources: official, | ||||
| 96 | + comparability: q?.comparability ?? null, | ||||
| 97 | + score, | ||||
| 98 | + status, | ||||
| 99 | + gap: Math.max(0, target.families - families), | ||||
| 100 | + }; | ||||
| 101 | +} | ||||
| 102 | + | ||||
| 103 | +export interface CoverageSummary { | ||||
| 104 | + quoted: number; | ||||
| 105 | + multiSource: number; | ||||
| 106 | + byFamilies: { ">=5": number; ">=3": number; ">=2": number; "1": number; "0": number }; | ||||
| 107 | + weightedScore: number; // 0..100 | ||||
| 108 | + tiers: Record<CoverageTier, { instruments: number; covered: number; attainment: number }>; | ||||
| 109 | + byAssetClass: Record<string, { instruments: number; multiSource: number; meanFamilies: number }>; | ||||
| 110 | + proxiesConfirming: number; | ||||
| 111 | + validatorsConfirming: number; | ||||
| 112 | +} | ||||
| 113 | + | ||||
| 114 | +export function coverageSummary(list: InstrumentCoverage[]): CoverageSummary { | ||||
| 115 | + const quoted = list.filter((c) => c.status !== "NO_DATA"); | ||||
| 116 | + const count = (min: number) => quoted.filter((c) => c.families >= min).length; | ||||
| 117 | + const tiers = {} as CoverageSummary["tiers"]; | ||||
| 118 | + for (const t of ["A", "B", "C", "D"] as CoverageTier[]) { | ||||
| 119 | + const items = quoted.filter((c) => c.tier === t); | ||||
| 120 | + const covered = items.filter((c) => c.status === "COVERED").length; | ||||
| 121 | + tiers[t] = { instruments: items.length, covered, attainment: items.length ? Math.round((covered / items.length) * 1000) / 10 : 0 }; | ||||
| 122 | + } | ||||
| 123 | + const byAssetClass: CoverageSummary["byAssetClass"] = {}; | ||||
| 124 | + for (const c of quoted) { | ||||
| 125 | + const b = (byAssetClass[c.assetClass] ??= { instruments: 0, multiSource: 0, meanFamilies: 0 }); | ||||
| 126 | + b.instruments++; | ||||
| 127 | + if (c.families >= 2) b.multiSource++; | ||||
| 128 | + b.meanFamilies += c.families; | ||||
| 129 | + } | ||||
| 130 | + for (const b of Object.values(byAssetClass)) b.meanFamilies = b.instruments ? Math.round((b.meanFamilies / b.instruments) * 100) / 100 : 0; | ||||
| 131 | + // Weighted redundancy: tier A counts 3×, B 2×, C/D 1×. | ||||
| 132 | + const w = (t: CoverageTier) => (t === "A" ? 3 : t === "B" ? 2 : 1); | ||||
| 133 | + const num = quoted.reduce((a, c) => a + w(c.tier) * c.score, 0); | ||||
| 134 | + const den = quoted.reduce((a, c) => a + w(c.tier), 0); | ||||
| 135 | + return { | ||||
| 136 | + quoted: quoted.length, | ||||
| 137 | + multiSource: count(2), | ||||
| 138 | + byFamilies: { ">=5": count(5), ">=3": count(3), ">=2": count(2), "1": quoted.filter((c) => c.families === 1).length, "0": quoted.filter((c) => c.families === 0).length }, | ||||
| 139 | + weightedScore: den ? Math.round((num / den) * 1000) / 10 : 0, | ||||
| 140 | + tiers, | ||||
| 141 | + byAssetClass, | ||||
| 142 | + proxiesConfirming: quoted.reduce((a, c) => a + c.proxies, 0), | ||||
| 143 | + validatorsConfirming: quoted.reduce((a, c) => a + c.validators, 0), | ||||
| 144 | + }; | ||||
| 145 | +} | ||||
| 146 | + | ||||
| 147 | +/** Coverage of every instrument that has a canonical quote (cheap: in-memory). */ | ||||
| 148 | +export function coverageAll(): InstrumentCoverage[] { | ||||
| 149 | + const out: InstrumentCoverage[] = []; | ||||
| 150 | + for (const q of quoteStore.all()) { | ||||
| 151 | + const inst = instruments.get(q.instrumentId); | ||||
| 152 | + if (inst && inst.isActive) out.push(coverageOf(inst, q)); | ||||
| 153 | + } | ||||
| 154 | + return out; | ||||
| 155 | +} | ||||
| 156 | + | ||||
| 157 | +/** Source-expansion queue: the most valuable gaps first (tier weight × gap, then lowest score). */ | ||||
| 158 | +export function expansionQueue(list: InstrumentCoverage[], limit = 50): InstrumentCoverage[] { | ||||
| 159 | + const w = (t: CoverageTier) => (t === "A" ? 3 : t === "B" ? 2 : 1); | ||||
| 160 | + return list | ||||
| 161 | + .filter((c) => c.status !== "NO_DATA" && c.status !== "COVERED") | ||||
| 162 | + .sort((a, b) => w(b.tier) * (b.gap + 1) - w(a.tier) * (a.gap + 1) || a.score - b.score || a.symbol.localeCompare(b.symbol)) | ||||
| 163 | + .slice(0, limit); | ||||
| 164 | +} | ||||
modified
apps/api/src/core/jobs.ts
+2 −0
@@ -18,6 +18,7 @@ import { pipeline } from "./pipeline.js"; | |||||
| 18 | import { quoteStore } from "./quotes.js"; | 18 | import { quoteStore } from "./quotes.js"; | ||
| 19 | import { telemetry } from "./telemetry.js"; | 19 | import { telemetry } from "./telemetry.js"; | ||
| 20 | import { connectorManager } from "./connector-manager.js"; | 20 | import { connectorManager } from "./connector-manager.js"; | ||
| 21 | +import { lineage } from "./lineage.js"; | ||||
| 21 | 22 | ||||
| 22 | /** Periodic maintenance: health snapshots, market open/close events, rollups, retention/archive, backups. */ | 23 | /** Periodic maintenance: health snapshots, market open/close events, rollups, retention/archive, backups. */ | ||
| 23 | export class Scheduler { | 24 | export class Scheduler { | ||
@@ -30,6 +31,7 @@ export class Scheduler { | |||||
| 30 | this.every(5_000, () => this.marketStates()); | 31 | this.every(5_000, () => this.marketStates()); | ||
| 31 | this.every(10_000, () => pipeline.sweep()); | 32 | this.every(10_000, () => pipeline.sweep()); | ||
| 32 | this.every(5 * 60_000, () => rollupBars(3)); | 33 | this.every(5 * 60_000, () => rollupBars(3)); | ||
| 34 | + this.every(5 * 60_000, () => lineage.recompute()); | ||||
| 33 | this.every(60_000, () => this.gauges()); | 35 | this.every(60_000, () => this.gauges()); | ||
| 34 | this.every(15 * 60_000, () => this.daily()); | 36 | this.every(15 * 60_000, () => this.daily()); | ||
| 35 | this.marketStates(); | 37 | this.marketStates(); | ||
added
apps/api/src/core/lineage.ts
+134 −0
@@ -0,0 +1,134 @@ | |||||
| 1 | +import type { Observation } from "@market-atlas/market-model"; | ||||
| 2 | +import { pool } from "../db/pool.js"; | ||||
| 3 | +import { logger } from "../logger.js"; | ||||
| 4 | +import { telemetry } from "./telemetry.js"; | ||||
| 5 | + | ||||
| 6 | +interface Sample { | ||||
| 7 | + v: number; | ||||
| 8 | + t: number; // source ts or receive ts | ||||
| 9 | +} | ||||
| 10 | + | ||||
| 11 | +/** | ||||
| 12 | + * Statistical shared-upstream detection. For every instrument, recent LAST_PRICE observations of each | ||||
| 13 | + * source are kept; two sources whose values coincide (identical price within a rounding epsilon and | ||||
| 14 | + * within 2 s) on the vast majority of aligned samples very likely share an upstream vendor. The result is | ||||
| 15 | + * a `similarity` in [0, 1] per source pair; pairs above the threshold are treated as one family by the | ||||
| 16 | + * consensus engine (independence weight 0.25) even without contractual knowledge of the vendor. | ||||
| 17 | + */ | ||||
| 18 | +export class LineageEngine { | ||||
| 19 | + private samples = new Map<string, Map<string, Sample[]>>(); // instrumentId -> sourceId -> ring | ||||
| 20 | + private similarity = new Map<string, { similarity: number; samples: number; instruments: number }>(); // "a|b" sorted | ||||
| 21 | + private inferredFamily = new Map<string, string>(); // sourceId -> inferred family id | ||||
| 22 | + readonly maxPerSource = 400; | ||||
| 23 | + readonly threshold = 0.92; | ||||
| 24 | + readonly minSamples = 120; | ||||
| 25 | + | ||||
| 26 | + observe(o: Observation): void { | ||||
| 27 | + if (o.field !== "LAST_PRICE") return; | ||||
| 28 | + let m = this.samples.get(o.instrumentId); | ||||
| 29 | + if (!m) { | ||||
| 30 | + m = new Map(); | ||||
| 31 | + this.samples.set(o.instrumentId, m); | ||||
| 32 | + } | ||||
| 33 | + let ring = m.get(o.sourceId); | ||||
| 34 | + if (!ring) { | ||||
| 35 | + ring = []; | ||||
| 36 | + m.set(o.sourceId, ring); | ||||
| 37 | + } | ||||
| 38 | + ring.push({ v: o.value, t: o.sourceTimestamp ?? o.receivedAt }); | ||||
| 39 | + if (ring.length > this.maxPerSource) ring.splice(0, ring.length - this.maxPerSource); | ||||
| 40 | + } | ||||
| 41 | + | ||||
| 42 | + /** Family override for a source when statistical lineage says it mirrors another source. */ | ||||
| 43 | + familyOf(sourceId: string): string | null { | ||||
| 44 | + return this.inferredFamily.get(sourceId) ?? null; | ||||
| 45 | + } | ||||
| 46 | + | ||||
| 47 | + pairs(): Array<{ sourceA: string; sourceB: string; similarity: number; samples: number; instruments: number; likelySharedUpstream: boolean }> { | ||||
| 48 | + return [...this.similarity.entries()] | ||||
| 49 | + .map(([k, v]) => { | ||||
| 50 | + const [sourceA, sourceB] = k.split("|") as [string, string]; | ||||
| 51 | + return { sourceA, sourceB, ...v, likelySharedUpstream: v.similarity >= this.threshold && v.samples >= this.minSamples }; | ||||
| 52 | + }) | ||||
| 53 | + .sort((a, b) => b.similarity - a.similarity); | ||||
| 54 | + } | ||||
| 55 | + | ||||
| 56 | + /** Recompute similarities (every 5 min) and persist them. */ | ||||
| 57 | + async recompute(): Promise<void> { | ||||
| 58 | + const acc = new Map<string, { matched: number; total: number; instruments: Set<string> }>(); | ||||
| 59 | + for (const [instrumentId, bySource] of this.samples) { | ||||
| 60 | + const sources = [...bySource.keys()]; | ||||
| 61 | + for (let i = 0; i < sources.length; i++) { | ||||
| 62 | + for (let j = i + 1; j < sources.length; j++) { | ||||
| 63 | + const a = bySource.get(sources[i]!)!; | ||||
| 64 | + const b = bySource.get(sources[j]!)!; | ||||
| 65 | + if (a.length < 30 || b.length < 30) continue; | ||||
| 66 | + const { matched, total } = align(a, b); | ||||
| 67 | + if (!total) continue; | ||||
| 68 | + const key = [sources[i]!, sources[j]!].sort().join("|"); | ||||
| 69 | + const e = acc.get(key) ?? { matched: 0, total: 0, instruments: new Set<string>() }; | ||||
| 70 | + e.matched += matched; | ||||
| 71 | + e.total += total; | ||||
| 72 | + e.instruments.add(instrumentId); | ||||
| 73 | + acc.set(key, e); | ||||
| 74 | + } | ||||
| 75 | + } | ||||
| 76 | + } | ||||
| 77 | + this.similarity.clear(); | ||||
| 78 | + this.inferredFamily.clear(); | ||||
| 79 | + const rows: unknown[][] = []; | ||||
| 80 | + for (const [key, e] of acc) { | ||||
| 81 | + const sim = e.matched / e.total; | ||||
| 82 | + this.similarity.set(key, { similarity: Math.round(sim * 1000) / 1000, samples: e.total, instruments: e.instruments.size }); | ||||
| 83 | + if (sim >= this.threshold && e.total >= this.minSamples) { | ||||
| 84 | + const [a, b] = key.split("|") as [string, string]; | ||||
| 85 | + const fam = this.inferredFamily.get(a) ?? this.inferredFamily.get(b) ?? `inferred:${a}+${b}`; | ||||
| 86 | + this.inferredFamily.set(a, fam); | ||||
| 87 | + this.inferredFamily.set(b, fam); | ||||
| 88 | + telemetry.inc("lineage_shared_upstream_pairs_total"); | ||||
| 89 | + } | ||||
| 90 | + const [a, b] = key.split("|"); | ||||
| 91 | + rows.push([a, b, Math.round(sim * 1000) / 1000, e.total, e.instruments.size]); | ||||
| 92 | + } | ||||
| 93 | + if (!rows.length) return; | ||||
| 94 | + try { | ||||
| 95 | + const tuples = rows.map((_, i) => `($${i * 5 + 1},$${i * 5 + 2},$${i * 5 + 3},$${i * 5 + 4},$${i * 5 + 5})`); | ||||
| 96 | + await pool.query( | ||||
| 97 | + `insert into source_lineage (source_a, source_b, similarity, samples, instruments) values ${tuples.join(",")} | ||||
| 98 | + on conflict (source_a, source_b) do update set similarity = excluded.similarity, samples = excluded.samples, instruments = excluded.instruments, computed_at = now()`, | ||||
| 99 | + rows.flat(), | ||||
| 100 | + ); | ||||
| 101 | + } catch (err) { | ||||
| 102 | + logger.warn({ err: err instanceof Error ? err.message : String(err) }, "lineage persist failed"); | ||||
| 103 | + } | ||||
| 104 | + } | ||||
| 105 | +} | ||||
| 106 | + | ||||
| 107 | +/** Count how many samples of `a` have a sample of `b` within 2 s with an identical value (relative 1e-6). */ | ||||
| 108 | +function align(a: Sample[], b: Sample[]): { matched: number; total: number } { | ||||
| 109 | + let j = 0; | ||||
| 110 | + let matched = 0; | ||||
| 111 | + let total = 0; | ||||
| 112 | + const sb = [...b].sort((x, y) => x.t - y.t); | ||||
| 113 | + for (const s of [...a].sort((x, y) => x.t - y.t)) { | ||||
| 114 | + while (j < sb.length && sb[j]!.t < s.t - 2000) j++; | ||||
| 115 | + let k = j; | ||||
| 116 | + let hit = false; | ||||
| 117 | + let found = false; | ||||
| 118 | + while (k < sb.length && sb[k]!.t <= s.t + 2000) { | ||||
| 119 | + found = true; | ||||
| 120 | + if (Math.abs(sb[k]!.v - s.v) <= Math.abs(s.v) * 1e-6) { | ||||
| 121 | + hit = true; | ||||
| 122 | + break; | ||||
| 123 | + } | ||||
| 124 | + k++; | ||||
| 125 | + } | ||||
| 126 | + if (found) { | ||||
| 127 | + total++; | ||||
| 128 | + if (hit) matched++; | ||||
| 129 | + } | ||||
| 130 | + } | ||||
| 131 | + return { matched, total }; | ||||
| 132 | +} | ||||
| 133 | + | ||||
| 134 | +export const lineage = new LineageEngine(); | ||||
modified
apps/api/src/core/observations.ts
+3 −2
@@ -63,7 +63,7 @@ export class ObservationWriter { | |||||
| 63 | this.partitionsReady.add(d); | 63 | this.partitionsReady.add(d); | ||
| 64 | } | 64 | } | ||
| 65 | } | 65 | } | ||
| 66 | − const cols = 17; | 66 | + const cols = 18; | ||
| 67 | const values: unknown[] = []; | 67 | const values: unknown[] = []; | ||
| 68 | const tuples: string[] = []; | 68 | const tuples: string[] = []; | ||
| 69 | batch.forEach((o, i) => { | 69 | batch.forEach((o, i) => { | ||
@@ -87,10 +87,11 @@ export class ObservationWriter { | |||||
| 87 | o.observationId, | 87 | o.observationId, | ||
| 88 | o.rawRef, | 88 | o.rawRef, | ||
| 89 | o.normalizerVersion, | 89 | o.normalizerVersion, | ||
| 90 | + o.observationType ?? null, | ||||
| 90 | ); | 91 | ); | ||
| 91 | }); | 92 | }); | ||
| 92 | await pool.query( | 93 | await pool.query( | ||
| 93 | − `insert into observations (received_at, source_ts, instrument_id, field, value, currency, source_id, connector_id, rights_status, realtime_status, timestamp_trust, confidence, latency_ms, sequence, fingerprint, raw_ref, normalizer_version) | 94 | + `insert into observations (received_at, source_ts, instrument_id, field, value, currency, source_id, connector_id, rights_status, realtime_status, timestamp_trust, confidence, latency_ms, sequence, fingerprint, raw_ref, normalizer_version, observation_type) | ||
| 94 | values ${tuples.join(",")}`, | 95 | values ${tuples.join(",")}`, | ||
| 95 | values, | 96 | values, | ||
| 96 | ); | 97 | ); | ||
modified
apps/api/src/core/pipeline.ts
+5 −1
@@ -14,6 +14,7 @@ import { instruments } from "./instruments.js"; | |||||
| 14 | import { observationWriter } from "./observations.js"; | 14 | import { observationWriter } from "./observations.js"; | ||
| 15 | import { quoteStore } from "./quotes.js"; | 15 | import { quoteStore } from "./quotes.js"; | ||
| 16 | import { telemetry } from "./telemetry.js"; | 16 | import { telemetry } from "./telemetry.js"; | ||
| 17 | +import { lineage } from "./lineage.js"; | ||||
| 17 | 18 | ||||
| 18 | export interface SourceInfo { | 19 | export interface SourceInfo { | ||
| 19 | family: string | null; | 20 | family: string | null; | ||
@@ -37,7 +38,7 @@ export class Pipeline { | |||||
| 37 | (sourceId) => { | 38 | (sourceId) => { | ||
| 38 | const info = this.sourceInfo.get(sourceId); | 39 | const info = this.sourceInfo.get(sourceId); | ||
| 39 | return { | 40 | return { | ||
| 40 | − family: info?.family ?? null, | 41 | + family: lineage.familyOf(sourceId) ?? info?.family ?? null, | ||
| 41 | reliability: health.sourceReliability(sourceId), | 42 | reliability: health.sourceReliability(sourceId), | ||
| 42 | realtimeStatus: info?.realtimeStatus ?? "UNKNOWN", | 43 | realtimeStatus: info?.realtimeStatus ?? "UNKNOWN", | ||
| 43 | isOfficial: info?.isOfficial ?? false, | 44 | isOfficial: info?.isOfficial ?? false, | ||
@@ -49,6 +50,7 @@ export class Pipeline { | |||||
| 49 | if (!ex || ex.sessions.continuous) return null; | 50 | if (!ex || ex.sessions.continuous) return null; | ||
| 50 | return calendar.state(ex.id) === "OPEN"; | 51 | return calendar.state(ex.id) === "OPEN"; | ||
| 51 | }, | 52 | }, | ||
| 53 | + (instrumentId) => instruments.get(instrumentId)?.assetClass ?? null, | ||||
| 52 | ); | 54 | ); | ||
| 53 | bus.subscribe("canonical.quote", (q) => barAggregator.onQuote(q)); | 55 | bus.subscribe("canonical.quote", (q) => barAggregator.onQuote(q)); | ||
| 54 | } | 56 | } | ||
@@ -86,6 +88,7 @@ export class Pipeline { | |||||
| 86 | if (this.shouldPersist(obs)) observationWriter.enqueue(obs); | 88 | if (this.shouldPersist(obs)) observationWriter.enqueue(obs); | ||
| 87 | else telemetry.inc("observations_sampled_out_total"); | 89 | else telemetry.inc("observations_sampled_out_total"); | ||
| 88 | this.consensus.ingest(obs); | 90 | this.consensus.ingest(obs); | ||
| 91 | + lineage.observe(obs); | ||||
| 89 | bus.publish("normalized.observation", obs); | 92 | bus.publish("normalized.observation", obs); | ||
| 90 | touched.set(inst.id, inst); | 93 | touched.set(inst.id, inst); | ||
| 91 | ids.push(inst.id); | 94 | ids.push(inst.id); | ||
@@ -145,6 +148,7 @@ export class Pipeline { | |||||
| 145 | field: n.field, | 148 | field: n.field, | ||
| 146 | value: n.value, | 149 | value: n.value, | ||
| 147 | currency: n.currency ?? inst.currency ?? null, | 150 | currency: n.currency ?? inst.currency ?? null, | ||
| 151 | + observationType: n.observationType, | ||||
| 148 | sourceTimestamp: n.sourceTimestamp, | 152 | sourceTimestamp: n.sourceTimestamp, | ||
| 149 | timestampTrust: n.timestampTrust, | 153 | timestampTrust: n.timestampTrust, | ||
| 150 | sequence: n.sequence ?? null, | 154 | sequence: n.sequence ?? null, | ||
modified
apps/api/src/core/quotes.ts
+9 −3
@@ -50,7 +50,7 @@ export class QuoteStore { | |||||
| 50 | this.flushing = true; | 50 | this.flushing = true; | ||
| 51 | const batch = [...this.dirty.values()].sort((a, b) => a.instrumentId.localeCompare(b.instrumentId)); | 51 | const batch = [...this.dirty.values()].sort((a, b) => a.instrumentId.localeCompare(b.instrumentId)); | ||
| 52 | this.dirty.clear(); | 52 | this.dirty.clear(); | ||
| 53 | − const cols = 25; | 53 | + const cols = 29; | ||
| 54 | const tuples: string[] = []; | 54 | const tuples: string[] = []; | ||
| 55 | const values: unknown[] = []; | 55 | const values: unknown[] = []; | ||
| 56 | batch.forEach((q, i) => { | 56 | batch.forEach((q, i) => { | ||
@@ -61,18 +61,20 @@ export class QuoteStore { | |||||
| 61 | q.sourceCount, q.dispersionBps, q.confidence, q.freshnessMs, q.realtimeStatus, q.rightsStatus, new Date(q.updatedAt).toISOString(), | 61 | q.sourceCount, q.dispersionBps, q.confidence, q.freshnessMs, q.realtimeStatus, q.rightsStatus, new Date(q.updatedAt).toISOString(), | ||
| 62 | q.sourceTimestamp == null ? null : new Date(q.sourceTimestamp).toISOString(), q.sessionHigh, q.sessionLow, | 62 | q.sourceTimestamp == null ? null : new Date(q.sourceTimestamp).toISOString(), q.sessionHigh, q.sessionLow, | ||
| 63 | JSON.stringify(q.contributions.slice(0, 20)), q.consensusVersion, | 63 | JSON.stringify(q.contributions.slice(0, 20)), q.consensusVersion, | ||
| 64 | + q.observationCount, q.proxyCount, q.validatorCount, q.comparability, | ||||
| 64 | ); | 65 | ); | ||
| 65 | }); | 66 | }); | ||
| 66 | try { | 67 | try { | ||
| 67 | await pool.query( | 68 | await pool.query( | ||
| 68 | `insert into canonical_quotes (instrument_id, symbol, price, open, high, low, previous_close, change, change_percent, volume, bid, ask, currency, | 69 | `insert into canonical_quotes (instrument_id, symbol, price, open, high, low, previous_close, change, change_percent, volume, bid, ask, currency, | ||
| 69 | − source_count, dispersion_bps, confidence, freshness_ms, realtime_status, rights_status, updated_at, source_ts, session_high, session_low, contributions, consensus_version) | 70 | + source_count, dispersion_bps, confidence, freshness_ms, realtime_status, rights_status, updated_at, source_ts, session_high, session_low, contributions, consensus_version, observation_count, proxy_count, validator_count, comparability) | ||
| 70 | values ${tuples.join(",")} | 71 | values ${tuples.join(",")} | ||
| 71 | on conflict (instrument_id) do update set symbol = excluded.symbol, price = excluded.price, open = excluded.open, high = excluded.high, low = excluded.low, | 72 | on conflict (instrument_id) do update set symbol = excluded.symbol, price = excluded.price, open = excluded.open, high = excluded.high, low = excluded.low, | ||
| 72 | previous_close = excluded.previous_close, change = excluded.change, change_percent = excluded.change_percent, volume = excluded.volume, bid = excluded.bid, ask = excluded.ask, | 73 | previous_close = excluded.previous_close, change = excluded.change, change_percent = excluded.change_percent, volume = excluded.volume, bid = excluded.bid, ask = excluded.ask, | ||
| 73 | currency = excluded.currency, source_count = excluded.source_count, dispersion_bps = excluded.dispersion_bps, confidence = excluded.confidence, freshness_ms = excluded.freshness_ms, | 74 | currency = excluded.currency, source_count = excluded.source_count, dispersion_bps = excluded.dispersion_bps, confidence = excluded.confidence, freshness_ms = excluded.freshness_ms, | ||
| 74 | realtime_status = excluded.realtime_status, rights_status = excluded.rights_status, updated_at = excluded.updated_at, source_ts = excluded.source_ts, | 75 | realtime_status = excluded.realtime_status, rights_status = excluded.rights_status, updated_at = excluded.updated_at, source_ts = excluded.source_ts, | ||
| 75 | − session_high = excluded.session_high, session_low = excluded.session_low, contributions = excluded.contributions, consensus_version = excluded.consensus_version`, | 76 | + session_high = excluded.session_high, session_low = excluded.session_low, contributions = excluded.contributions, consensus_version = excluded.consensus_version, | ||
| 77 | + observation_count = excluded.observation_count, proxy_count = excluded.proxy_count, validator_count = excluded.validator_count, comparability = excluded.comparability`, | ||||
| 76 | values, | 78 | values, | ||
| 77 | ); | 79 | ); | ||
| 78 | } catch (err) { | 80 | } catch (err) { | ||
@@ -101,6 +103,10 @@ export function rowToQuote(row: any): CanonicalQuote { | |||||
| 101 | ask: row.ask, | 103 | ask: row.ask, | ||
| 102 | currency: row.currency, | 104 | currency: row.currency, | ||
| 103 | sourceCount: row.source_count, | 105 | sourceCount: row.source_count, | ||
| 106 | + observationCount: row.observation_count ?? 0, | ||||
| 107 | + proxyCount: row.proxy_count ?? 0, | ||||
| 108 | + validatorCount: row.validator_count ?? 0, | ||||
| 109 | + comparability: row.comparability ?? null, | ||||
| 104 | dispersionBps: row.dispersion_bps, | 110 | dispersionBps: row.dispersion_bps, | ||
| 105 | confidence: row.confidence, | 111 | confidence: row.confidence, | ||
| 106 | freshnessMs: row.freshness_ms, | 112 | freshnessMs: row.freshness_ms, | ||
modified
apps/api/src/seed/countries.json
+319 −53
@@ -1,54 +1,320 @@ | |||||
| 1 | [ | 1 | [ | ||
| 2 | − { "code": "US", "name": "United States", "region": "North America", "currency": "USD" }, | 2 | + { | ||
| 3 | − { "code": "CA", "name": "Canada", "region": "North America", "currency": "CAD" }, | 3 | + "code": "US", | ||
| 4 | − { "code": "MX", "name": "Mexico", "region": "North America", "currency": "MXN" }, | 4 | + "name": "United States", | ||
| 5 | − { "code": "BR", "name": "Brazil", "region": "South America", "currency": "BRL" }, | 5 | + "region": "North America", | ||
| 6 | − { "code": "AR", "name": "Argentina", "region": "South America", "currency": "ARS" }, | 6 | + "currency": "USD" | ||
| 7 | − { "code": "CL", "name": "Chile", "region": "South America", "currency": "CLP" }, | 7 | + }, | ||
| 8 | − { "code": "CO", "name": "Colombia", "region": "South America", "currency": "COP" }, | 8 | + { | ||
| 9 | − { "code": "GB", "name": "United Kingdom", "region": "Europe", "currency": "GBP" }, | 9 | + "code": "CA", | ||
| 10 | − { "code": "IE", "name": "Ireland", "region": "Europe", "currency": "EUR" }, | 10 | + "name": "Canada", | ||
| 11 | − { "code": "FR", "name": "France", "region": "Europe", "currency": "EUR" }, | 11 | + "region": "North America", | ||
| 12 | − { "code": "DE", "name": "Germany", "region": "Europe", "currency": "EUR" }, | 12 | + "currency": "CAD" | ||
| 13 | − { "code": "NL", "name": "Netherlands", "region": "Europe", "currency": "EUR" }, | 13 | + }, | ||
| 14 | − { "code": "BE", "name": "Belgium", "region": "Europe", "currency": "EUR" }, | 14 | + { | ||
| 15 | − { "code": "PT", "name": "Portugal", "region": "Europe", "currency": "EUR" }, | 15 | + "code": "MX", | ||
| 16 | − { "code": "ES", "name": "Spain", "region": "Europe", "currency": "EUR" }, | 16 | + "name": "Mexico", | ||
| 17 | − { "code": "IT", "name": "Italy", "region": "Europe", "currency": "EUR" }, | 17 | + "region": "North America", | ||
| 18 | − { "code": "CH", "name": "Switzerland", "region": "Europe", "currency": "CHF" }, | 18 | + "currency": "MXN" | ||
| 19 | − { "code": "AT", "name": "Austria", "region": "Europe", "currency": "EUR" }, | 19 | + }, | ||
| 20 | − { "code": "SE", "name": "Sweden", "region": "Europe", "currency": "SEK" }, | 20 | + { | ||
| 21 | − { "code": "NO", "name": "Norway", "region": "Europe", "currency": "NOK" }, | 21 | + "code": "BR", | ||
| 22 | − { "code": "DK", "name": "Denmark", "region": "Europe", "currency": "DKK" }, | 22 | + "name": "Brazil", | ||
| 23 | − { "code": "FI", "name": "Finland", "region": "Europe", "currency": "EUR" }, | 23 | + "region": "South America", | ||
| 24 | − { "code": "PL", "name": "Poland", "region": "Europe", "currency": "PLN" }, | 24 | + "currency": "BRL" | ||
| 25 | − { "code": "CZ", "name": "Czechia", "region": "Europe", "currency": "CZK" }, | 25 | + }, | ||
| 26 | − { "code": "HU", "name": "Hungary", "region": "Europe", "currency": "HUF" }, | 26 | + { | ||
| 27 | − { "code": "GR", "name": "Greece", "region": "Europe", "currency": "EUR" }, | 27 | + "code": "AR", | ||
| 28 | − { "code": "TR", "name": "Türkiye", "region": "Europe", "currency": "TRY" }, | 28 | + "name": "Argentina", | ||
| 29 | − { "code": "RU", "name": "Russia", "region": "Europe", "currency": "RUB" }, | 29 | + "region": "South America", | ||
| 30 | − { "code": "IL", "name": "Israel", "region": "Middle East", "currency": "ILS" }, | 30 | + "currency": "ARS" | ||
| 31 | − { "code": "SA", "name": "Saudi Arabia", "region": "Middle East", "currency": "SAR" }, | 31 | + }, | ||
| 32 | − { "code": "AE", "name": "United Arab Emirates", "region": "Middle East", "currency": "AED" }, | 32 | + { | ||
| 33 | − { "code": "QA", "name": "Qatar", "region": "Middle East", "currency": "QAR" }, | 33 | + "code": "CL", | ||
| 34 | − { "code": "EG", "name": "Egypt", "region": "Africa", "currency": "EGP" }, | 34 | + "name": "Chile", | ||
| 35 | − { "code": "ZA", "name": "South Africa", "region": "Africa", "currency": "ZAR" }, | 35 | + "region": "South America", | ||
| 36 | − { "code": "NG", "name": "Nigeria", "region": "Africa", "currency": "NGN" }, | 36 | + "currency": "CLP" | ||
| 37 | − { "code": "KE", "name": "Kenya", "region": "Africa", "currency": "KES" }, | 37 | + }, | ||
| 38 | − { "code": "JP", "name": "Japan", "region": "Asia-Pacific", "currency": "JPY" }, | 38 | + { | ||
| 39 | − { "code": "CN", "name": "China", "region": "Asia-Pacific", "currency": "CNY" }, | 39 | + "code": "CO", | ||
| 40 | − { "code": "HK", "name": "Hong Kong", "region": "Asia-Pacific", "currency": "HKD" }, | 40 | + "name": "Colombia", | ||
| 41 | − { "code": "TW", "name": "Taiwan", "region": "Asia-Pacific", "currency": "TWD" }, | 41 | + "region": "South America", | ||
| 42 | − { "code": "KR", "name": "South Korea", "region": "Asia-Pacific", "currency": "KRW" }, | 42 | + "currency": "COP" | ||
| 43 | − { "code": "SG", "name": "Singapore", "region": "Asia-Pacific", "currency": "SGD" }, | 43 | + }, | ||
| 44 | − { "code": "MY", "name": "Malaysia", "region": "Asia-Pacific", "currency": "MYR" }, | 44 | + { | ||
| 45 | − { "code": "TH", "name": "Thailand", "region": "Asia-Pacific", "currency": "THB" }, | 45 | + "code": "GB", | ||
| 46 | − { "code": "ID", "name": "Indonesia", "region": "Asia-Pacific", "currency": "IDR" }, | 46 | + "name": "United Kingdom", | ||
| 47 | − { "code": "PH", "name": "Philippines", "region": "Asia-Pacific", "currency": "PHP" }, | 47 | + "region": "Europe", | ||
| 48 | − { "code": "VN", "name": "Vietnam", "region": "Asia-Pacific", "currency": "VND" }, | 48 | + "currency": "GBP" | ||
| 49 | − { "code": "IN", "name": "India", "region": "Asia-Pacific", "currency": "INR" }, | 49 | + }, | ||
| 50 | − { "code": "AU", "name": "Australia", "region": "Asia-Pacific", "currency": "AUD" }, | 50 | + { | ||
| 51 | − { "code": "NZ", "name": "New Zealand", "region": "Asia-Pacific", "currency": "NZD" }, | 51 | + "code": "IE", | ||
| 52 | − { "code": "EU", "name": "Euro area", "region": "Europe", "currency": "EUR" }, | 52 | + "name": "Ireland", | ||
| 53 | − { "code": "XX", "name": "Global / cross-border", "region": "Global", "currency": null } | 53 | + "region": "Europe", | ||
| 54 | −] | 54 | + "currency": "EUR" | ||
| 55 | + }, | ||||
| 56 | + { | ||||
| 57 | + "code": "FR", | ||||
| 58 | + "name": "France", | ||||
| 59 | + "region": "Europe", | ||||
| 60 | + "currency": "EUR" | ||||
| 61 | + }, | ||||
| 62 | + { | ||||
| 63 | + "code": "DE", | ||||
| 64 | + "name": "Germany", | ||||
| 65 | + "region": "Europe", | ||||
| 66 | + "currency": "EUR" | ||||
| 67 | + }, | ||||
| 68 | + { | ||||
| 69 | + "code": "NL", | ||||
| 70 | + "name": "Netherlands", | ||||
| 71 | + "region": "Europe", | ||||
| 72 | + "currency": "EUR" | ||||
| 73 | + }, | ||||
| 74 | + { | ||||
| 75 | + "code": "BE", | ||||
| 76 | + "name": "Belgium", | ||||
| 77 | + "region": "Europe", | ||||
| 78 | + "currency": "EUR" | ||||
| 79 | + }, | ||||
| 80 | + { | ||||
| 81 | + "code": "PT", | ||||
| 82 | + "name": "Portugal", | ||||
| 83 | + "region": "Europe", | ||||
| 84 | + "currency": "EUR" | ||||
| 85 | + }, | ||||
| 86 | + { | ||||
| 87 | + "code": "ES", | ||||
| 88 | + "name": "Spain", | ||||
| 89 | + "region": "Europe", | ||||
| 90 | + "currency": "EUR" | ||||
| 91 | + }, | ||||
| 92 | + { | ||||
| 93 | + "code": "IT", | ||||
| 94 | + "name": "Italy", | ||||
| 95 | + "region": "Europe", | ||||
| 96 | + "currency": "EUR" | ||||
| 97 | + }, | ||||
| 98 | + { | ||||
| 99 | + "code": "CH", | ||||
| 100 | + "name": "Switzerland", | ||||
| 101 | + "region": "Europe", | ||||
| 102 | + "currency": "CHF" | ||||
| 103 | + }, | ||||
| 104 | + { | ||||
| 105 | + "code": "AT", | ||||
| 106 | + "name": "Austria", | ||||
| 107 | + "region": "Europe", | ||||
| 108 | + "currency": "EUR" | ||||
| 109 | + }, | ||||
| 110 | + { | ||||
| 111 | + "code": "SE", | ||||
| 112 | + "name": "Sweden", | ||||
| 113 | + "region": "Europe", | ||||
| 114 | + "currency": "SEK" | ||||
| 115 | + }, | ||||
| 116 | + { | ||||
| 117 | + "code": "NO", | ||||
| 118 | + "name": "Norway", | ||||
| 119 | + "region": "Europe", | ||||
| 120 | + "currency": "NOK" | ||||
| 121 | + }, | ||||
| 122 | + { | ||||
| 123 | + "code": "DK", | ||||
| 124 | + "name": "Denmark", | ||||
| 125 | + "region": "Europe", | ||||
| 126 | + "currency": "DKK" | ||||
| 127 | + }, | ||||
| 128 | + { | ||||
| 129 | + "code": "FI", | ||||
| 130 | + "name": "Finland", | ||||
| 131 | + "region": "Europe", | ||||
| 132 | + "currency": "EUR" | ||||
| 133 | + }, | ||||
| 134 | + { | ||||
| 135 | + "code": "PL", | ||||
| 136 | + "name": "Poland", | ||||
| 137 | + "region": "Europe", | ||||
| 138 | + "currency": "PLN" | ||||
| 139 | + }, | ||||
| 140 | + { | ||||
| 141 | + "code": "CZ", | ||||
| 142 | + "name": "Czechia", | ||||
| 143 | + "region": "Europe", | ||||
| 144 | + "currency": "CZK" | ||||
| 145 | + }, | ||||
| 146 | + { | ||||
| 147 | + "code": "HU", | ||||
| 148 | + "name": "Hungary", | ||||
| 149 | + "region": "Europe", | ||||
| 150 | + "currency": "HUF" | ||||
| 151 | + }, | ||||
| 152 | + { | ||||
| 153 | + "code": "GR", | ||||
| 154 | + "name": "Greece", | ||||
| 155 | + "region": "Europe", | ||||
| 156 | + "currency": "EUR" | ||||
| 157 | + }, | ||||
| 158 | + { | ||||
| 159 | + "code": "TR", | ||||
| 160 | + "name": "Türkiye", | ||||
| 161 | + "region": "Europe", | ||||
| 162 | + "currency": "TRY" | ||||
| 163 | + }, | ||||
| 164 | + { | ||||
| 165 | + "code": "RU", | ||||
| 166 | + "name": "Russia", | ||||
| 167 | + "region": "Europe", | ||||
| 168 | + "currency": "RUB" | ||||
| 169 | + }, | ||||
| 170 | + { | ||||
| 171 | + "code": "IL", | ||||
| 172 | + "name": "Israel", | ||||
| 173 | + "region": "Middle East", | ||||
| 174 | + "currency": "ILS" | ||||
| 175 | + }, | ||||
| 176 | + { | ||||
| 177 | + "code": "SA", | ||||
| 178 | + "name": "Saudi Arabia", | ||||
| 179 | + "region": "Middle East", | ||||
| 180 | + "currency": "SAR" | ||||
| 181 | + }, | ||||
| 182 | + { | ||||
| 183 | + "code": "AE", | ||||
| 184 | + "name": "United Arab Emirates", | ||||
| 185 | + "region": "Middle East", | ||||
| 186 | + "currency": "AED" | ||||
| 187 | + }, | ||||
| 188 | + { | ||||
| 189 | + "code": "QA", | ||||
| 190 | + "name": "Qatar", | ||||
| 191 | + "region": "Middle East", | ||||
| 192 | + "currency": "QAR" | ||||
| 193 | + }, | ||||
| 194 | + { | ||||
| 195 | + "code": "EG", | ||||
| 196 | + "name": "Egypt", | ||||
| 197 | + "region": "Africa", | ||||
| 198 | + "currency": "EGP" | ||||
| 199 | + }, | ||||
| 200 | + { | ||||
| 201 | + "code": "ZA", | ||||
| 202 | + "name": "South Africa", | ||||
| 203 | + "region": "Africa", | ||||
| 204 | + "currency": "ZAR" | ||||
| 205 | + }, | ||||
| 206 | + { | ||||
| 207 | + "code": "NG", | ||||
| 208 | + "name": "Nigeria", | ||||
| 209 | + "region": "Africa", | ||||
| 210 | + "currency": "NGN" | ||||
| 211 | + }, | ||||
| 212 | + { | ||||
| 213 | + "code": "KE", | ||||
| 214 | + "name": "Kenya", | ||||
| 215 | + "region": "Africa", | ||||
| 216 | + "currency": "KES" | ||||
| 217 | + }, | ||||
| 218 | + { | ||||
| 219 | + "code": "JP", | ||||
| 220 | + "name": "Japan", | ||||
| 221 | + "region": "Asia-Pacific", | ||||
| 222 | + "currency": "JPY" | ||||
| 223 | + }, | ||||
| 224 | + { | ||||
| 225 | + "code": "CN", | ||||
| 226 | + "name": "China", | ||||
| 227 | + "region": "Asia-Pacific", | ||||
| 228 | + "currency": "CNY" | ||||
| 229 | + }, | ||||
| 230 | + { | ||||
| 231 | + "code": "HK", | ||||
| 232 | + "name": "Hong Kong", | ||||
| 233 | + "region": "Asia-Pacific", | ||||
| 234 | + "currency": "HKD" | ||||
| 235 | + }, | ||||
| 236 | + { | ||||
| 237 | + "code": "TW", | ||||
| 238 | + "name": "Taiwan", | ||||
| 239 | + "region": "Asia-Pacific", | ||||
| 240 | + "currency": "TWD" | ||||
| 241 | + }, | ||||
| 242 | + { | ||||
| 243 | + "code": "KR", | ||||
| 244 | + "name": "South Korea", | ||||
| 245 | + "region": "Asia-Pacific", | ||||
| 246 | + "currency": "KRW" | ||||
| 247 | + }, | ||||
| 248 | + { | ||||
| 249 | + "code": "SG", | ||||
| 250 | + "name": "Singapore", | ||||
| 251 | + "region": "Asia-Pacific", | ||||
| 252 | + "currency": "SGD" | ||||
| 253 | + }, | ||||
| 254 | + { | ||||
| 255 | + "code": "MY", | ||||
| 256 | + "name": "Malaysia", | ||||
| 257 | + "region": "Asia-Pacific", | ||||
| 258 | + "currency": "MYR" | ||||
| 259 | + }, | ||||
| 260 | + { | ||||
| 261 | + "code": "TH", | ||||
| 262 | + "name": "Thailand", | ||||
| 263 | + "region": "Asia-Pacific", | ||||
| 264 | + "currency": "THB" | ||||
| 265 | + }, | ||||
| 266 | + { | ||||
| 267 | + "code": "ID", | ||||
| 268 | + "name": "Indonesia", | ||||
| 269 | + "region": "Asia-Pacific", | ||||
| 270 | + "currency": "IDR" | ||||
| 271 | + }, | ||||
| 272 | + { | ||||
| 273 | + "code": "PH", | ||||
| 274 | + "name": "Philippines", | ||||
| 275 | + "region": "Asia-Pacific", | ||||
| 276 | + "currency": "PHP" | ||||
| 277 | + }, | ||||
| 278 | + { | ||||
| 279 | + "code": "VN", | ||||
| 280 | + "name": "Vietnam", | ||||
| 281 | + "region": "Asia-Pacific", | ||||
| 282 | + "currency": "VND" | ||||
| 283 | + }, | ||||
| 284 | + { | ||||
| 285 | + "code": "IN", | ||||
| 286 | + "name": "India", | ||||
| 287 | + "region": "Asia-Pacific", | ||||
| 288 | + "currency": "INR" | ||||
| 289 | + }, | ||||
| 290 | + { | ||||
| 291 | + "code": "AU", | ||||
| 292 | + "name": "Australia", | ||||
| 293 | + "region": "Asia-Pacific", | ||||
| 294 | + "currency": "AUD" | ||||
| 295 | + }, | ||||
| 296 | + { | ||||
| 297 | + "code": "NZ", | ||||
| 298 | + "name": "New Zealand", | ||||
| 299 | + "region": "Asia-Pacific", | ||||
| 300 | + "currency": "NZD" | ||||
| 301 | + }, | ||||
| 302 | + { | ||||
| 303 | + "code": "EU", | ||||
| 304 | + "name": "Euro area", | ||||
| 305 | + "region": "Europe", | ||||
| 306 | + "currency": "EUR" | ||||
| 307 | + }, | ||||
| 308 | + { | ||||
| 309 | + "code": "XX", | ||||
| 310 | + "name": "Global / cross-border", | ||||
| 311 | + "region": "Global", | ||||
| 312 | + "currency": null | ||||
| 313 | + }, | ||||
| 314 | + { | ||||
| 315 | + "code": "SC", | ||||
| 316 | + "name": "Seychelles", | ||||
| 317 | + "region": "Africa", | ||||
| 318 | + "currency": "SCR" | ||||
| 319 | + } | ||||
| 320 | +] | ||||
| \ No newline at end of file | |||||
modified
apps/api/src/seed/exchanges.json
+1731 −62
@@ -1,63 +1,1732 @@ | |||||
| 1 | [ | 1 | [ | ||
| 2 | − { "id": "xnys", "mic": "XNYS", "name": "New York Stock Exchange", "operator": "Intercontinental Exchange", "country": "US", "city": "New York", "timezone": "America/New_York", "currency": "USD", "website": "https://www.nyse.com", "lat": 40.7069, "lon": -74.0113, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:30", "close": "16:00" }], "pre": { "open": "04:00", "close": "09:30" }, "post": { "open": "16:00", "close": "20:00" } } }, | 2 | + { | ||
| 3 | − { "id": "xnas", "mic": "XNAS", "name": "Nasdaq Stock Market", "operator": "Nasdaq, Inc.", "country": "US", "city": "New York", "timezone": "America/New_York", "currency": "USD", "website": "https://www.nasdaq.com", "lat": 40.7565, "lon": -73.9860, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:30", "close": "16:00" }], "pre": { "open": "04:00", "close": "09:30" }, "post": { "open": "16:00", "close": "20:00" } } }, | 3 | + "id": "xnys", | ||
| 4 | − { "id": "xase", "mic": "XASE", "name": "NYSE American", "operator": "Intercontinental Exchange", "country": "US", "city": "New York", "timezone": "America/New_York", "currency": "USD", "website": "https://www.nyse.com/markets/nyse-american", "lat": 40.7069, "lon": -74.0113, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:30", "close": "16:00" }], "pre": { "open": "04:00", "close": "09:30" }, "post": { "open": "16:00", "close": "20:00" } } }, | 4 | + "mic": "XNYS", | ||
| 5 | − { "id": "arcx", "mic": "ARCX", "name": "NYSE Arca", "operator": "Intercontinental Exchange", "country": "US", "city": "Chicago", "timezone": "America/New_York", "currency": "USD", "website": "https://www.nyse.com/markets/nyse-arca", "lat": 41.8781, "lon": -87.6298, "asset_classes": ["ETF", "EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "16:00" }], "pre": { "open": "04:00", "close": "09:30" }, "post": { "open": "16:00", "close": "20:00" } } }, | 5 | + "name": "New York Stock Exchange", | ||
| 6 | − { "id": "bats", "mic": "BATS", "name": "Cboe BZX Exchange", "operator": "Cboe Global Markets", "country": "US", "city": "Chicago", "timezone": "America/Chicago", "currency": "USD", "website": "https://www.cboe.com", "lat": 41.8827, "lon": -87.6324, "asset_classes": ["EQUITY", "ETF", "INDEX"], "sessions": { "regular": [{ "open": "08:30", "close": "15:00" }], "pre": { "open": "03:00", "close": "08:30" }, "post": { "open": "15:00", "close": "19:00" } } }, | 6 | + "operator": "Intercontinental Exchange", | ||
| 7 | − { "id": "xcbo", "mic": "XCBO", "name": "Cboe Options Exchange", "operator": "Cboe Global Markets", "country": "US", "city": "Chicago", "timezone": "America/Chicago", "currency": "USD", "website": "https://www.cboe.com", "lat": 41.8827, "lon": -87.6324, "asset_classes": ["INDEX", "OPTION"], "sessions": { "regular": [{ "open": "08:30", "close": "15:15" }] } }, | 7 | + "country": "US", | ||
| 8 | − { "id": "xcme", "mic": "XCME", "name": "CME Group (Globex)", "operator": "CME Group", "country": "US", "city": "Chicago", "timezone": "America/Chicago", "currency": "USD", "website": "https://www.cmegroup.com", "lat": 41.8785, "lon": -87.6355, "asset_classes": ["FUTURE", "COMMODITY"], "sessions": { "regular": [{ "open": "17:00", "close": "24:00" }, { "open": "00:00", "close": "16:00" }], "weekdays": [0, 1, 2, 3, 4, 5] } }, | 8 | + "city": "New York", | ||
| 9 | − { "id": "xtse", "mic": "XTSE", "name": "Toronto Stock Exchange", "operator": "TMX Group", "country": "CA", "city": "Toronto", "timezone": "America/Toronto", "currency": "CAD", "website": "https://www.tsx.com", "lat": 43.6487, "lon": -79.3817, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:30", "close": "16:00" }], "post": { "open": "16:15", "close": "17:00" } } }, | 9 | + "timezone": "America/New_York", | ||
| 10 | − { "id": "xmex", "mic": "XMEX", "name": "Bolsa Mexicana de Valores", "operator": "Grupo BMV", "country": "MX", "city": "Mexico City", "timezone": "America/Mexico_City", "currency": "MXN", "website": "https://www.bmv.com.mx", "lat": 19.4260, "lon": -99.1730, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "08:30", "close": "15:00" }] } }, | 10 | + "currency": "USD", | ||
| 11 | − { "id": "bvmf", "mic": "BVMF", "name": "B3 — Brasil Bolsa Balcão", "operator": "B3 S.A.", "country": "BR", "city": "São Paulo", "timezone": "America/Sao_Paulo", "currency": "BRL", "website": "https://www.b3.com.br", "lat": -23.5475, "lon": -46.6361, "asset_classes": ["EQUITY", "FUTURE"], "sessions": { "regular": [{ "open": "10:00", "close": "17:55" }] } }, | 11 | + "website": "https://www.nyse.com", | ||
| 12 | − { "id": "xbue", "mic": "XBUE", "name": "Bolsas y Mercados Argentinos", "operator": "BYMA", "country": "AR", "city": "Buenos Aires", "timezone": "America/Argentina/Buenos_Aires", "currency": "ARS", "website": "https://www.byma.com.ar", "lat": -34.6037, "lon": -58.3737, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "11:00", "close": "17:00" }] } }, | 12 | + "lat": 40.7069, | ||
| 13 | − { "id": "xsgo", "mic": "XSGO", "name": "Bolsa de Santiago", "operator": "Bolsa de Santiago", "country": "CL", "city": "Santiago", "timezone": "America/Santiago", "currency": "CLP", "website": "https://www.bolsadesantiago.com", "lat": -33.4390, "lon": -70.6510, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "16:00" }] } }, | 13 | + "lon": -74.0113, | ||
| 14 | − { "id": "xlon", "mic": "XLON", "name": "London Stock Exchange", "operator": "LSEG", "country": "GB", "city": "London", "timezone": "Europe/London", "currency": "GBP", "website": "https://www.londonstockexchange.com", "lat": 51.5155, "lon": -0.0987, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "08:00", "close": "16:30" }] } }, | 14 | + "asset_classes": [ | ||
| 15 | − { "id": "xdub", "mic": "XDUB", "name": "Euronext Dublin", "operator": "Euronext", "country": "IE", "city": "Dublin", "timezone": "Europe/Dublin", "currency": "EUR", "website": "https://www.euronext.com/en/markets/dublin", "lat": 53.3441, "lon": -6.2675, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "08:00", "close": "16:28" }] } }, | 15 | + "EQUITY", | ||
| 16 | − { "id": "xpar", "mic": "XPAR", "name": "Euronext Paris", "operator": "Euronext", "country": "FR", "city": "Paris", "timezone": "Europe/Paris", "currency": "EUR", "website": "https://www.euronext.com/en/markets/paris", "lat": 48.8698, "lon": 2.3397, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, | 16 | + "ETF" | ||
| 17 | − { "id": "xams", "mic": "XAMS", "name": "Euronext Amsterdam", "operator": "Euronext", "country": "NL", "city": "Amsterdam", "timezone": "Europe/Amsterdam", "currency": "EUR", "website": "https://www.euronext.com/en/markets/amsterdam", "lat": 52.3702, "lon": 4.8952, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, | 17 | + ], | ||
| 18 | − { "id": "xbru", "mic": "XBRU", "name": "Euronext Brussels", "operator": "Euronext", "country": "BE", "city": "Brussels", "timezone": "Europe/Brussels", "currency": "EUR", "website": "https://www.euronext.com/en/markets/brussels", "lat": 50.8467, "lon": 4.3499, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, | 18 | + "sessions": { | ||
| 19 | − { "id": "xlis", "mic": "XLIS", "name": "Euronext Lisbon", "operator": "Euronext", "country": "PT", "city": "Lisbon", "timezone": "Europe/Lisbon", "currency": "EUR", "website": "https://www.euronext.com/en/markets/lisbon", "lat": 38.7078, "lon": -9.1366, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "08:00", "close": "16:30" }] } }, | 19 | + "regular": [ | ||
| 20 | − { "id": "xmad", "mic": "XMAD", "name": "Bolsa de Madrid", "operator": "SIX / BME", "country": "ES", "city": "Madrid", "timezone": "Europe/Madrid", "currency": "EUR", "website": "https://www.bolsamadrid.es", "lat": 40.4168, "lon": -3.6936, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, | 20 | + { | ||
| 21 | − { "id": "xmil", "mic": "XMIL", "name": "Borsa Italiana", "operator": "Euronext", "country": "IT", "city": "Milan", "timezone": "Europe/Rome", "currency": "EUR", "website": "https://www.borsaitaliana.it", "lat": 45.4654, "lon": 9.1859, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, | 21 | + "open": "09:30", | ||
| 22 | − { "id": "xetr", "mic": "XETR", "name": "Xetra (Frankfurt)", "operator": "Deutsche Börse", "country": "DE", "city": "Frankfurt", "timezone": "Europe/Berlin", "currency": "EUR", "website": "https://www.xetra.com", "lat": 50.1155, "lon": 8.6742, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, | 22 | + "close": "16:00" | ||
| 23 | − { "id": "xswx", "mic": "XSWX", "name": "SIX Swiss Exchange", "operator": "SIX Group", "country": "CH", "city": "Zurich", "timezone": "Europe/Zurich", "currency": "CHF", "website": "https://www.six-group.com", "lat": 47.3667, "lon": 8.5378, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, | 23 | + } | ||
| 24 | − { "id": "xwbo", "mic": "XWBO", "name": "Wiener Börse", "operator": "Wiener Börse AG", "country": "AT", "city": "Vienna", "timezone": "Europe/Vienna", "currency": "EUR", "website": "https://www.wienerborse.at", "lat": 48.2116, "lon": 16.3702, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, | 24 | + ], | ||
| 25 | − { "id": "xsto", "mic": "XSTO", "name": "Nasdaq Stockholm", "operator": "Nasdaq Nordic", "country": "SE", "city": "Stockholm", "timezone": "Europe/Stockholm", "currency": "SEK", "website": "https://www.nasdaqomxnordic.com", "lat": 59.3300, "lon": 18.0700, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:30" }] } }, | 25 | + "pre": { | ||
| 26 | − { "id": "xcse", "mic": "XCSE", "name": "Nasdaq Copenhagen", "operator": "Nasdaq Nordic", "country": "DK", "city": "Copenhagen", "timezone": "Europe/Copenhagen", "currency": "DKK", "website": "https://www.nasdaqomxnordic.com", "lat": 55.6761, "lon": 12.5683, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:00" }] } }, | 26 | + "open": "04:00", | ||
| 27 | − { "id": "xhel", "mic": "XHEL", "name": "Nasdaq Helsinki", "operator": "Nasdaq Nordic", "country": "FI", "city": "Helsinki", "timezone": "Europe/Helsinki", "currency": "EUR", "website": "https://www.nasdaqomxnordic.com", "lat": 60.1699, "lon": 24.9384, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "18:30" }] } }, | 27 | + "close": "09:30" | ||
| 28 | − { "id": "xosl", "mic": "XOSL", "name": "Oslo Børs", "operator": "Euronext", "country": "NO", "city": "Oslo", "timezone": "Europe/Oslo", "currency": "NOK", "website": "https://www.euronext.com/en/markets/oslo", "lat": 59.9139, "lon": 10.7522, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "16:20" }] } }, | 28 | + }, | ||
| 29 | − { "id": "xwar", "mic": "XWAR", "name": "Warsaw Stock Exchange", "operator": "GPW", "country": "PL", "city": "Warsaw", "timezone": "Europe/Warsaw", "currency": "PLN", "website": "https://www.gpw.pl", "lat": 52.2297, "lon": 21.0122, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:00" }] } }, | 29 | + "post": { | ||
| 30 | − { "id": "xpra", "mic": "XPRA", "name": "Prague Stock Exchange", "operator": "Wiener Börse AG", "country": "CZ", "city": "Prague", "timezone": "Europe/Prague", "currency": "CZK", "website": "https://www.pse.cz", "lat": 50.0755, "lon": 14.4378, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "16:20" }] } }, | 30 | + "open": "16:00", | ||
| 31 | − { "id": "xbud", "mic": "XBUD", "name": "Budapest Stock Exchange", "operator": "BSE", "country": "HU", "city": "Budapest", "timezone": "Europe/Budapest", "currency": "HUF", "website": "https://www.bse.hu", "lat": 47.4979, "lon": 19.0402, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:00" }] } }, | 31 | + "close": "20:00" | ||
| 32 | − { "id": "xath", "mic": "XATH", "name": "Athens Stock Exchange", "operator": "ATHEX", "country": "GR", "city": "Athens", "timezone": "Europe/Athens", "currency": "EUR", "website": "https://www.athexgroup.gr", "lat": 37.9838, "lon": 23.7275, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "17:20" }] } }, | 32 | + } | ||
| 33 | − { "id": "xist", "mic": "XIST", "name": "Borsa İstanbul", "operator": "Borsa İstanbul", "country": "TR", "city": "Istanbul", "timezone": "Europe/Istanbul", "currency": "TRY", "website": "https://www.borsaistanbul.com", "lat": 41.1085, "lon": 29.0288, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "18:00" }] } }, | 33 | + } | ||
| 34 | − { "id": "xmos", "mic": "MISX", "name": "Moscow Exchange", "operator": "MOEX", "country": "RU", "city": "Moscow", "timezone": "Europe/Moscow", "currency": "RUB", "website": "https://www.moex.com", "lat": 55.7558, "lon": 37.6173, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "18:50" }] } }, | 34 | + }, | ||
| 35 | − { "id": "xtae", "mic": "XTAE", "name": "Tel Aviv Stock Exchange", "operator": "TASE", "country": "IL", "city": "Tel Aviv", "timezone": "Asia/Jerusalem", "currency": "ILS", "website": "https://www.tase.co.il", "lat": 32.0636, "lon": 34.7708, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "17:15" }], "weekdays": [0, 1, 2, 3, 4] } }, | 35 | + { | ||
| 36 | − { "id": "xsau", "mic": "XSAU", "name": "Saudi Exchange (Tadawul)", "operator": "Saudi Tadawul Group", "country": "SA", "city": "Riyadh", "timezone": "Asia/Riyadh", "currency": "SAR", "website": "https://www.saudiexchange.sa", "lat": 24.7136, "lon": 46.6753, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "15:00" }], "weekdays": [0, 1, 2, 3, 4] } }, | 36 | + "id": "xnas", | ||
| 37 | − { "id": "xdfm", "mic": "XDFM", "name": "Dubai Financial Market", "operator": "DFM", "country": "AE", "city": "Dubai", "timezone": "Asia/Dubai", "currency": "AED", "website": "https://www.dfm.ae", "lat": 25.2172, "lon": 55.2790, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "14:45" }] } }, | 37 | + "mic": "XNAS", | ||
| 38 | − { "id": "dsmd", "mic": "DSMD", "name": "Qatar Stock Exchange", "operator": "QSE", "country": "QA", "city": "Doha", "timezone": "Asia/Qatar", "currency": "QAR", "website": "https://www.qe.com.qa", "lat": 25.2854, "lon": 51.5310, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "13:15" }], "weekdays": [0, 1, 2, 3, 4] } }, | 38 | + "name": "Nasdaq Stock Market", | ||
| 39 | − { "id": "xcai", "mic": "XCAI", "name": "Egyptian Exchange", "operator": "EGX", "country": "EG", "city": "Cairo", "timezone": "Africa/Cairo", "currency": "EGP", "website": "https://www.egx.com.eg", "lat": 30.0444, "lon": 31.2357, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "14:30" }], "weekdays": [0, 1, 2, 3, 4] } }, | 39 | + "operator": "Nasdaq, Inc.", | ||
| 40 | − { "id": "xjse", "mic": "XJSE", "name": "Johannesburg Stock Exchange", "operator": "JSE Limited", "country": "ZA", "city": "Johannesburg", "timezone": "Africa/Johannesburg", "currency": "ZAR", "website": "https://www.jse.co.za", "lat": -26.1076, "lon": 28.0567, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "17:00" }] } }, | 40 | + "country": "US", | ||
| 41 | − { "id": "xnsa", "mic": "XNSA", "name": "Nigerian Exchange", "operator": "NGX Group", "country": "NG", "city": "Lagos", "timezone": "Africa/Lagos", "currency": "NGN", "website": "https://ngxgroup.com", "lat": 6.4541, "lon": 3.3947, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "14:30" }] } }, | 41 | + "city": "New York", | ||
| 42 | − { "id": "xnai", "mic": "XNAI", "name": "Nairobi Securities Exchange", "operator": "NSE", "country": "KE", "city": "Nairobi", "timezone": "Africa/Nairobi", "currency": "KES", "website": "https://www.nse.co.ke", "lat": -1.2921, "lon": 36.8219, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "15:00" }] } }, | 42 | + "timezone": "America/New_York", | ||
| 43 | − { "id": "xnse", "mic": "XNSE", "name": "National Stock Exchange of India", "operator": "NSE India", "country": "IN", "city": "Mumbai", "timezone": "Asia/Kolkata", "currency": "INR", "website": "https://www.nseindia.com", "lat": 19.0607, "lon": 72.8611, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:15", "close": "15:30" }] } }, | 43 | + "currency": "USD", | ||
| 44 | − { "id": "xbom", "mic": "XBOM", "name": "BSE (Bombay Stock Exchange)", "operator": "BSE Ltd", "country": "IN", "city": "Mumbai", "timezone": "Asia/Kolkata", "currency": "INR", "website": "https://www.bseindia.com", "lat": 18.9299, "lon": 72.8336, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:15", "close": "15:30" }] } }, | 44 | + "website": "https://www.nasdaq.com", | ||
| 45 | − { "id": "xshg", "mic": "XSHG", "name": "Shanghai Stock Exchange", "operator": "SSE", "country": "CN", "city": "Shanghai", "timezone": "Asia/Shanghai", "currency": "CNY", "website": "https://www.sse.com.cn", "lat": 31.2304, "lon": 121.4737, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "11:30" }, { "open": "13:00", "close": "15:00" }] } }, | 45 | + "lat": 40.7565, | ||
| 46 | − { "id": "xshe", "mic": "XSHE", "name": "Shenzhen Stock Exchange", "operator": "SZSE", "country": "CN", "city": "Shenzhen", "timezone": "Asia/Shanghai", "currency": "CNY", "website": "https://www.szse.cn", "lat": 22.5431, "lon": 114.0579, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "11:30" }, { "open": "13:00", "close": "15:00" }] } }, | 46 | + "lon": -73.986, | ||
| 47 | − { "id": "xhkg", "mic": "XHKG", "name": "Hong Kong Stock Exchange", "operator": "HKEX", "country": "HK", "city": "Hong Kong", "timezone": "Asia/Hong_Kong", "currency": "HKD", "website": "https://www.hkex.com.hk", "lat": 22.2830, "lon": 114.1588, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:30", "close": "12:00" }, { "open": "13:00", "close": "16:00" }] } }, | 47 | + "asset_classes": [ | ||
| 48 | − { "id": "xtai", "mic": "XTAI", "name": "Taiwan Stock Exchange", "operator": "TWSE", "country": "TW", "city": "Taipei", "timezone": "Asia/Taipei", "currency": "TWD", "website": "https://www.twse.com.tw", "lat": 25.0330, "lon": 121.5654, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "13:30" }] } }, | 48 | + "EQUITY", | ||
| 49 | − { "id": "xkrx", "mic": "XKRX", "name": "Korea Exchange", "operator": "KRX", "country": "KR", "city": "Seoul", "timezone": "Asia/Seoul", "currency": "KRW", "website": "https://global.krx.co.kr", "lat": 37.5236, "lon": 126.9260, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "15:30" }] } }, | 49 | + "ETF" | ||
| 50 | − { "id": "xtks", "mic": "XTKS", "name": "Tokyo Stock Exchange", "operator": "Japan Exchange Group", "country": "JP", "city": "Tokyo", "timezone": "Asia/Tokyo", "currency": "JPY", "website": "https://www.jpx.co.jp", "lat": 35.6817, "lon": 139.7785, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "09:00", "close": "11:30" }, { "open": "12:30", "close": "15:30" }] } }, | 50 | + ], | ||
| 51 | − { "id": "xses", "mic": "XSES", "name": "Singapore Exchange", "operator": "SGX Group", "country": "SG", "city": "Singapore", "timezone": "Asia/Singapore", "currency": "SGD", "website": "https://www.sgx.com", "lat": 1.2792, "lon": 103.8507, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "12:00" }, { "open": "13:00", "close": "17:00" }] } }, | 51 | + "sessions": { | ||
| 52 | − { "id": "xkls", "mic": "XKLS", "name": "Bursa Malaysia", "operator": "Bursa Malaysia", "country": "MY", "city": "Kuala Lumpur", "timezone": "Asia/Kuala_Lumpur", "currency": "MYR", "website": "https://www.bursamalaysia.com", "lat": 3.1490, "lon": 101.6959, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "12:30" }, { "open": "14:30", "close": "17:00" }] } }, | 52 | + "regular": [ | ||
| 53 | − { "id": "xbkk", "mic": "XBKK", "name": "Stock Exchange of Thailand", "operator": "SET", "country": "TH", "city": "Bangkok", "timezone": "Asia/Bangkok", "currency": "THB", "website": "https://www.set.or.th", "lat": 13.7563, "lon": 100.5018, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "12:30" }, { "open": "14:30", "close": "16:30" }] } }, | 53 | + { | ||
| 54 | − { "id": "xidx", "mic": "XIDX", "name": "Indonesia Stock Exchange", "operator": "IDX", "country": "ID", "city": "Jakarta", "timezone": "Asia/Jakarta", "currency": "IDR", "website": "https://www.idx.co.id", "lat": -6.2245, "lon": 106.8090, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "11:30" }, { "open": "13:30", "close": "15:50" }] } }, | 54 | + "open": "09:30", | ||
| 55 | − { "id": "xphs", "mic": "XPHS", "name": "Philippine Stock Exchange", "operator": "PSE", "country": "PH", "city": "Manila", "timezone": "Asia/Manila", "currency": "PHP", "website": "https://www.pse.com.ph", "lat": 14.5547, "lon": 121.0244, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:30", "close": "12:00" }, { "open": "13:00", "close": "15:00" }] } }, | 55 | + "close": "16:00" | ||
| 56 | − { "id": "xstc", "mic": "XSTC", "name": "Ho Chi Minh Stock Exchange", "operator": "HOSE", "country": "VN", "city": "Ho Chi Minh City", "timezone": "Asia/Ho_Chi_Minh", "currency": "VND", "website": "https://www.hsx.vn", "lat": 10.7769, "lon": 106.7009, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "09:00", "close": "11:30" }, { "open": "13:00", "close": "15:00" }] } }, | 56 | + } | ||
| 57 | − { "id": "xasx", "mic": "XASX", "name": "Australian Securities Exchange", "operator": "ASX Ltd", "country": "AU", "city": "Sydney", "timezone": "Australia/Sydney", "currency": "AUD", "website": "https://www.asx.com.au", "lat": -33.8651, "lon": 151.2099, "asset_classes": ["EQUITY", "ETF"], "sessions": { "regular": [{ "open": "10:00", "close": "16:00" }] } }, | 57 | + ], | ||
| 58 | − { "id": "xnze", "mic": "XNZE", "name": "NZX", "operator": "NZX Limited", "country": "NZ", "city": "Wellington", "timezone": "Pacific/Auckland", "currency": "NZD", "website": "https://www.nzx.com", "lat": -41.2865, "lon": 174.7762, "asset_classes": ["EQUITY"], "sessions": { "regular": [{ "open": "10:00", "close": "16:45" }] } }, | 58 | + "pre": { | ||
| 59 | − { "id": "coinbase", "mic": null, "name": "Coinbase Exchange", "operator": "Coinbase Global", "country": "US", "city": "San Francisco", "timezone": "UTC", "currency": "USD", "website": "https://exchange.coinbase.com", "lat": 37.7749, "lon": -122.4194, "asset_classes": ["CRYPTO"], "sessions": { "regular": [], "continuous": true } }, | 59 | + "open": "04:00", | ||
| 60 | − { "id": "kraken", "mic": null, "name": "Kraken", "operator": "Payward, Inc.", "country": "US", "city": "San Francisco", "timezone": "UTC", "currency": "USD", "website": "https://www.kraken.com", "lat": 37.79, "lon": -122.40, "asset_classes": ["CRYPTO"], "sessions": { "regular": [], "continuous": true } }, | 60 | + "close": "09:30" | ||
| 61 | − { "id": "binance", "mic": null, "name": "Binance", "operator": "Binance", "country": "XX", "city": null, "timezone": "UTC", "currency": "USDT", "website": "https://www.binance.com", "lat": 43.7384, "lon": 7.4246, "asset_classes": ["CRYPTO"], "sessions": { "regular": [], "continuous": true } }, | 61 | + }, | ||
| 62 | − { "id": "okx", "mic": null, "name": "OKX", "operator": "OKX", "country": "XX", "city": null, "timezone": "UTC", "currency": "USDT", "website": "https://www.okx.com", "lat": 1.3521, "lon": 103.8198, "asset_classes": ["CRYPTO"], "sessions": { "regular": [], "continuous": true } } | 62 | + "post": { | ||
| 63 | −] | 63 | + "open": "16:00", | ||
| 64 | + "close": "20:00" | ||||
| 65 | + } | ||||
| 66 | + } | ||||
| 67 | + }, | ||||
| 68 | + { | ||||
| 69 | + "id": "xase", | ||||
| 70 | + "mic": "XASE", | ||||
| 71 | + "name": "NYSE American", | ||||
| 72 | + "operator": "Intercontinental Exchange", | ||||
| 73 | + "country": "US", | ||||
| 74 | + "city": "New York", | ||||
| 75 | + "timezone": "America/New_York", | ||||
| 76 | + "currency": "USD", | ||||
| 77 | + "website": "https://www.nyse.com/markets/nyse-american", | ||||
| 78 | + "lat": 40.7069, | ||||
| 79 | + "lon": -74.0113, | ||||
| 80 | + "asset_classes": [ | ||||
| 81 | + "EQUITY", | ||||
| 82 | + "ETF" | ||||
| 83 | + ], | ||||
| 84 | + "sessions": { | ||||
| 85 | + "regular": [ | ||||
| 86 | + { | ||||
| 87 | + "open": "09:30", | ||||
| 88 | + "close": "16:00" | ||||
| 89 | + } | ||||
| 90 | + ], | ||||
| 91 | + "pre": { | ||||
| 92 | + "open": "04:00", | ||||
| 93 | + "close": "09:30" | ||||
| 94 | + }, | ||||
| 95 | + "post": { | ||||
| 96 | + "open": "16:00", | ||||
| 97 | + "close": "20:00" | ||||
| 98 | + } | ||||
| 99 | + } | ||||
| 100 | + }, | ||||
| 101 | + { | ||||
| 102 | + "id": "arcx", | ||||
| 103 | + "mic": "ARCX", | ||||
| 104 | + "name": "NYSE Arca", | ||||
| 105 | + "operator": "Intercontinental Exchange", | ||||
| 106 | + "country": "US", | ||||
| 107 | + "city": "Chicago", | ||||
| 108 | + "timezone": "America/New_York", | ||||
| 109 | + "currency": "USD", | ||||
| 110 | + "website": "https://www.nyse.com/markets/nyse-arca", | ||||
| 111 | + "lat": 41.8781, | ||||
| 112 | + "lon": -87.6298, | ||||
| 113 | + "asset_classes": [ | ||||
| 114 | + "ETF", | ||||
| 115 | + "EQUITY" | ||||
| 116 | + ], | ||||
| 117 | + "sessions": { | ||||
| 118 | + "regular": [ | ||||
| 119 | + { | ||||
| 120 | + "open": "09:30", | ||||
| 121 | + "close": "16:00" | ||||
| 122 | + } | ||||
| 123 | + ], | ||||
| 124 | + "pre": { | ||||
| 125 | + "open": "04:00", | ||||
| 126 | + "close": "09:30" | ||||
| 127 | + }, | ||||
| 128 | + "post": { | ||||
| 129 | + "open": "16:00", | ||||
| 130 | + "close": "20:00" | ||||
| 131 | + } | ||||
| 132 | + } | ||||
| 133 | + }, | ||||
| 134 | + { | ||||
| 135 | + "id": "bats", | ||||
| 136 | + "mic": "BATS", | ||||
| 137 | + "name": "Cboe BZX Exchange", | ||||
| 138 | + "operator": "Cboe Global Markets", | ||||
| 139 | + "country": "US", | ||||
| 140 | + "city": "Chicago", | ||||
| 141 | + "timezone": "America/Chicago", | ||||
| 142 | + "currency": "USD", | ||||
| 143 | + "website": "https://www.cboe.com", | ||||
| 144 | + "lat": 41.8827, | ||||
| 145 | + "lon": -87.6324, | ||||
| 146 | + "asset_classes": [ | ||||
| 147 | + "EQUITY", | ||||
| 148 | + "ETF", | ||||
| 149 | + "INDEX" | ||||
| 150 | + ], | ||||
| 151 | + "sessions": { | ||||
| 152 | + "regular": [ | ||||
| 153 | + { | ||||
| 154 | + "open": "08:30", | ||||
| 155 | + "close": "15:00" | ||||
| 156 | + } | ||||
| 157 | + ], | ||||
| 158 | + "pre": { | ||||
| 159 | + "open": "03:00", | ||||
| 160 | + "close": "08:30" | ||||
| 161 | + }, | ||||
| 162 | + "post": { | ||||
| 163 | + "open": "15:00", | ||||
| 164 | + "close": "19:00" | ||||
| 165 | + } | ||||
| 166 | + } | ||||
| 167 | + }, | ||||
| 168 | + { | ||||
| 169 | + "id": "xcbo", | ||||
| 170 | + "mic": "XCBO", | ||||
| 171 | + "name": "Cboe Options Exchange", | ||||
| 172 | + "operator": "Cboe Global Markets", | ||||
| 173 | + "country": "US", | ||||
| 174 | + "city": "Chicago", | ||||
| 175 | + "timezone": "America/Chicago", | ||||
| 176 | + "currency": "USD", | ||||
| 177 | + "website": "https://www.cboe.com", | ||||
| 178 | + "lat": 41.8827, | ||||
| 179 | + "lon": -87.6324, | ||||
| 180 | + "asset_classes": [ | ||||
| 181 | + "INDEX", | ||||
| 182 | + "OPTION" | ||||
| 183 | + ], | ||||
| 184 | + "sessions": { | ||||
| 185 | + "regular": [ | ||||
| 186 | + { | ||||
| 187 | + "open": "08:30", | ||||
| 188 | + "close": "15:15" | ||||
| 189 | + } | ||||
| 190 | + ] | ||||
| 191 | + } | ||||
| 192 | + }, | ||||
| 193 | + { | ||||
| 194 | + "id": "xcme", | ||||
| 195 | + "mic": "XCME", | ||||
| 196 | + "name": "CME Group (Globex)", | ||||
| 197 | + "operator": "CME Group", | ||||
| 198 | + "country": "US", | ||||
| 199 | + "city": "Chicago", | ||||
| 200 | + "timezone": "America/Chicago", | ||||
| 201 | + "currency": "USD", | ||||
| 202 | + "website": "https://www.cmegroup.com", | ||||
| 203 | + "lat": 41.8785, | ||||
| 204 | + "lon": -87.6355, | ||||
| 205 | + "asset_classes": [ | ||||
| 206 | + "FUTURE", | ||||
| 207 | + "COMMODITY" | ||||
| 208 | + ], | ||||
| 209 | + "sessions": { | ||||
| 210 | + "regular": [ | ||||
| 211 | + { | ||||
| 212 | + "open": "17:00", | ||||
| 213 | + "close": "24:00" | ||||
| 214 | + }, | ||||
| 215 | + { | ||||
| 216 | + "open": "00:00", | ||||
| 217 | + "close": "16:00" | ||||
| 218 | + } | ||||
| 219 | + ], | ||||
| 220 | + "weekdays": [ | ||||
| 221 | + 0, | ||||
| 222 | + 1, | ||||
| 223 | + 2, | ||||
| 224 | + 3, | ||||
| 225 | + 4, | ||||
| 226 | + 5 | ||||
| 227 | + ] | ||||
| 228 | + } | ||||
| 229 | + }, | ||||
| 230 | + { | ||||
| 231 | + "id": "xtse", | ||||
| 232 | + "mic": "XTSE", | ||||
| 233 | + "name": "Toronto Stock Exchange", | ||||
| 234 | + "operator": "TMX Group", | ||||
| 235 | + "country": "CA", | ||||
| 236 | + "city": "Toronto", | ||||
| 237 | + "timezone": "America/Toronto", | ||||
| 238 | + "currency": "CAD", | ||||
| 239 | + "website": "https://www.tsx.com", | ||||
| 240 | + "lat": 43.6487, | ||||
| 241 | + "lon": -79.3817, | ||||
| 242 | + "asset_classes": [ | ||||
| 243 | + "EQUITY", | ||||
| 244 | + "ETF" | ||||
| 245 | + ], | ||||
| 246 | + "sessions": { | ||||
| 247 | + "regular": [ | ||||
| 248 | + { | ||||
| 249 | + "open": "09:30", | ||||
| 250 | + "close": "16:00" | ||||
| 251 | + } | ||||
| 252 | + ], | ||||
| 253 | + "post": { | ||||
| 254 | + "open": "16:15", | ||||
| 255 | + "close": "17:00" | ||||
| 256 | + } | ||||
| 257 | + } | ||||
| 258 | + }, | ||||
| 259 | + { | ||||
| 260 | + "id": "xmex", | ||||
| 261 | + "mic": "XMEX", | ||||
| 262 | + "name": "Bolsa Mexicana de Valores", | ||||
| 263 | + "operator": "Grupo BMV", | ||||
| 264 | + "country": "MX", | ||||
| 265 | + "city": "Mexico City", | ||||
| 266 | + "timezone": "America/Mexico_City", | ||||
| 267 | + "currency": "MXN", | ||||
| 268 | + "website": "https://www.bmv.com.mx", | ||||
| 269 | + "lat": 19.426, | ||||
| 270 | + "lon": -99.173, | ||||
| 271 | + "asset_classes": [ | ||||
| 272 | + "EQUITY" | ||||
| 273 | + ], | ||||
| 274 | + "sessions": { | ||||
| 275 | + "regular": [ | ||||
| 276 | + { | ||||
| 277 | + "open": "08:30", | ||||
| 278 | + "close": "15:00" | ||||
| 279 | + } | ||||
| 280 | + ] | ||||
| 281 | + } | ||||
| 282 | + }, | ||||
| 283 | + { | ||||
| 284 | + "id": "bvmf", | ||||
| 285 | + "mic": "BVMF", | ||||
| 286 | + "name": "B3 — Brasil Bolsa Balcão", | ||||
| 287 | + "operator": "B3 S.A.", | ||||
| 288 | + "country": "BR", | ||||
| 289 | + "city": "São Paulo", | ||||
| 290 | + "timezone": "America/Sao_Paulo", | ||||
| 291 | + "currency": "BRL", | ||||
| 292 | + "website": "https://www.b3.com.br", | ||||
| 293 | + "lat": -23.5475, | ||||
| 294 | + "lon": -46.6361, | ||||
| 295 | + "asset_classes": [ | ||||
| 296 | + "EQUITY", | ||||
| 297 | + "FUTURE" | ||||
| 298 | + ], | ||||
| 299 | + "sessions": { | ||||
| 300 | + "regular": [ | ||||
| 301 | + { | ||||
| 302 | + "open": "10:00", | ||||
| 303 | + "close": "17:55" | ||||
| 304 | + } | ||||
| 305 | + ] | ||||
| 306 | + } | ||||
| 307 | + }, | ||||
| 308 | + { | ||||
| 309 | + "id": "xbue", | ||||
| 310 | + "mic": "XBUE", | ||||
| 311 | + "name": "Bolsas y Mercados Argentinos", | ||||
| 312 | + "operator": "BYMA", | ||||
| 313 | + "country": "AR", | ||||
| 314 | + "city": "Buenos Aires", | ||||
| 315 | + "timezone": "America/Argentina/Buenos_Aires", | ||||
| 316 | + "currency": "ARS", | ||||
| 317 | + "website": "https://www.byma.com.ar", | ||||
| 318 | + "lat": -34.6037, | ||||
| 319 | + "lon": -58.3737, | ||||
| 320 | + "asset_classes": [ | ||||
| 321 | + "EQUITY" | ||||
| 322 | + ], | ||||
| 323 | + "sessions": { | ||||
| 324 | + "regular": [ | ||||
| 325 | + { | ||||
| 326 | + "open": "11:00", | ||||
| 327 | + "close": "17:00" | ||||
| 328 | + } | ||||
| 329 | + ] | ||||
| 330 | + } | ||||
| 331 | + }, | ||||
| 332 | + { | ||||
| 333 | + "id": "xsgo", | ||||
| 334 | + "mic": "XSGO", | ||||
| 335 | + "name": "Bolsa de Santiago", | ||||
| 336 | + "operator": "Bolsa de Santiago", | ||||
| 337 | + "country": "CL", | ||||
| 338 | + "city": "Santiago", | ||||
| 339 | + "timezone": "America/Santiago", | ||||
| 340 | + "currency": "CLP", | ||||
| 341 | + "website": "https://www.bolsadesantiago.com", | ||||
| 342 | + "lat": -33.439, | ||||
| 343 | + "lon": -70.651, | ||||
| 344 | + "asset_classes": [ | ||||
| 345 | + "EQUITY" | ||||
| 346 | + ], | ||||
| 347 | + "sessions": { | ||||
| 348 | + "regular": [ | ||||
| 349 | + { | ||||
| 350 | + "open": "09:30", | ||||
| 351 | + "close": "16:00" | ||||
| 352 | + } | ||||
| 353 | + ] | ||||
| 354 | + } | ||||
| 355 | + }, | ||||
| 356 | + { | ||||
| 357 | + "id": "xlon", | ||||
| 358 | + "mic": "XLON", | ||||
| 359 | + "name": "London Stock Exchange", | ||||
| 360 | + "operator": "LSEG", | ||||
| 361 | + "country": "GB", | ||||
| 362 | + "city": "London", | ||||
| 363 | + "timezone": "Europe/London", | ||||
| 364 | + "currency": "GBP", | ||||
| 365 | + "website": "https://www.londonstockexchange.com", | ||||
| 366 | + "lat": 51.5155, | ||||
| 367 | + "lon": -0.0987, | ||||
| 368 | + "asset_classes": [ | ||||
| 369 | + "EQUITY", | ||||
| 370 | + "ETF" | ||||
| 371 | + ], | ||||
| 372 | + "sessions": { | ||||
| 373 | + "regular": [ | ||||
| 374 | + { | ||||
| 375 | + "open": "08:00", | ||||
| 376 | + "close": "16:30" | ||||
| 377 | + } | ||||
| 378 | + ] | ||||
| 379 | + } | ||||
| 380 | + }, | ||||
| 381 | + { | ||||
| 382 | + "id": "xdub", | ||||
| 383 | + "mic": "XDUB", | ||||
| 384 | + "name": "Euronext Dublin", | ||||
| 385 | + "operator": "Euronext", | ||||
| 386 | + "country": "IE", | ||||
| 387 | + "city": "Dublin", | ||||
| 388 | + "timezone": "Europe/Dublin", | ||||
| 389 | + "currency": "EUR", | ||||
| 390 | + "website": "https://www.euronext.com/en/markets/dublin", | ||||
| 391 | + "lat": 53.3441, | ||||
| 392 | + "lon": -6.2675, | ||||
| 393 | + "asset_classes": [ | ||||
| 394 | + "EQUITY" | ||||
| 395 | + ], | ||||
| 396 | + "sessions": { | ||||
| 397 | + "regular": [ | ||||
| 398 | + { | ||||
| 399 | + "open": "08:00", | ||||
| 400 | + "close": "16:28" | ||||
| 401 | + } | ||||
| 402 | + ] | ||||
| 403 | + } | ||||
| 404 | + }, | ||||
| 405 | + { | ||||
| 406 | + "id": "xpar", | ||||
| 407 | + "mic": "XPAR", | ||||
| 408 | + "name": "Euronext Paris", | ||||
| 409 | + "operator": "Euronext", | ||||
| 410 | + "country": "FR", | ||||
| 411 | + "city": "Paris", | ||||
| 412 | + "timezone": "Europe/Paris", | ||||
| 413 | + "currency": "EUR", | ||||
| 414 | + "website": "https://www.euronext.com/en/markets/paris", | ||||
| 415 | + "lat": 48.8698, | ||||
| 416 | + "lon": 2.3397, | ||||
| 417 | + "asset_classes": [ | ||||
| 418 | + "EQUITY", | ||||
| 419 | + "ETF" | ||||
| 420 | + ], | ||||
| 421 | + "sessions": { | ||||
| 422 | + "regular": [ | ||||
| 423 | + { | ||||
| 424 | + "open": "09:00", | ||||
| 425 | + "close": "17:30" | ||||
| 426 | + } | ||||
| 427 | + ] | ||||
| 428 | + } | ||||
| 429 | + }, | ||||
| 430 | + { | ||||
| 431 | + "id": "xams", | ||||
| 432 | + "mic": "XAMS", | ||||
| 433 | + "name": "Euronext Amsterdam", | ||||
| 434 | + "operator": "Euronext", | ||||
| 435 | + "country": "NL", | ||||
| 436 | + "city": "Amsterdam", | ||||
| 437 | + "timezone": "Europe/Amsterdam", | ||||
| 438 | + "currency": "EUR", | ||||
| 439 | + "website": "https://www.euronext.com/en/markets/amsterdam", | ||||
| 440 | + "lat": 52.3702, | ||||
| 441 | + "lon": 4.8952, | ||||
| 442 | + "asset_classes": [ | ||||
| 443 | + "EQUITY", | ||||
| 444 | + "ETF" | ||||
| 445 | + ], | ||||
| 446 | + "sessions": { | ||||
| 447 | + "regular": [ | ||||
| 448 | + { | ||||
| 449 | + "open": "09:00", | ||||
| 450 | + "close": "17:30" | ||||
| 451 | + } | ||||
| 452 | + ] | ||||
| 453 | + } | ||||
| 454 | + }, | ||||
| 455 | + { | ||||
| 456 | + "id": "xbru", | ||||
| 457 | + "mic": "XBRU", | ||||
| 458 | + "name": "Euronext Brussels", | ||||
| 459 | + "operator": "Euronext", | ||||
| 460 | + "country": "BE", | ||||
| 461 | + "city": "Brussels", | ||||
| 462 | + "timezone": "Europe/Brussels", | ||||
| 463 | + "currency": "EUR", | ||||
| 464 | + "website": "https://www.euronext.com/en/markets/brussels", | ||||
| 465 | + "lat": 50.8467, | ||||
| 466 | + "lon": 4.3499, | ||||
| 467 | + "asset_classes": [ | ||||
| 468 | + "EQUITY" | ||||
| 469 | + ], | ||||
| 470 | + "sessions": { | ||||
| 471 | + "regular": [ | ||||
| 472 | + { | ||||
| 473 | + "open": "09:00", | ||||
| 474 | + "close": "17:30" | ||||
| 475 | + } | ||||
| 476 | + ] | ||||
| 477 | + } | ||||
| 478 | + }, | ||||
| 479 | + { | ||||
| 480 | + "id": "xlis", | ||||
| 481 | + "mic": "XLIS", | ||||
| 482 | + "name": "Euronext Lisbon", | ||||
| 483 | + "operator": "Euronext", | ||||
| 484 | + "country": "PT", | ||||
| 485 | + "city": "Lisbon", | ||||
| 486 | + "timezone": "Europe/Lisbon", | ||||
| 487 | + "currency": "EUR", | ||||
| 488 | + "website": "https://www.euronext.com/en/markets/lisbon", | ||||
| 489 | + "lat": 38.7078, | ||||
| 490 | + "lon": -9.1366, | ||||
| 491 | + "asset_classes": [ | ||||
| 492 | + "EQUITY" | ||||
| 493 | + ], | ||||
| 494 | + "sessions": { | ||||
| 495 | + "regular": [ | ||||
| 496 | + { | ||||
| 497 | + "open": "08:00", | ||||
| 498 | + "close": "16:30" | ||||
| 499 | + } | ||||
| 500 | + ] | ||||
| 501 | + } | ||||
| 502 | + }, | ||||
| 503 | + { | ||||
| 504 | + "id": "xmad", | ||||
| 505 | + "mic": "XMAD", | ||||
| 506 | + "name": "Bolsa de Madrid", | ||||
| 507 | + "operator": "SIX / BME", | ||||
| 508 | + "country": "ES", | ||||
| 509 | + "city": "Madrid", | ||||
| 510 | + "timezone": "Europe/Madrid", | ||||
| 511 | + "currency": "EUR", | ||||
| 512 | + "website": "https://www.bolsamadrid.es", | ||||
| 513 | + "lat": 40.4168, | ||||
| 514 | + "lon": -3.6936, | ||||
| 515 | + "asset_classes": [ | ||||
| 516 | + "EQUITY" | ||||
| 517 | + ], | ||||
| 518 | + "sessions": { | ||||
| 519 | + "regular": [ | ||||
| 520 | + { | ||||
| 521 | + "open": "09:00", | ||||
| 522 | + "close": "17:30" | ||||
| 523 | + } | ||||
| 524 | + ] | ||||
| 525 | + } | ||||
| 526 | + }, | ||||
| 527 | + { | ||||
| 528 | + "id": "xmil", | ||||
| 529 | + "mic": "XMIL", | ||||
| 530 | + "name": "Borsa Italiana", | ||||
| 531 | + "operator": "Euronext", | ||||
| 532 | + "country": "IT", | ||||
| 533 | + "city": "Milan", | ||||
| 534 | + "timezone": "Europe/Rome", | ||||
| 535 | + "currency": "EUR", | ||||
| 536 | + "website": "https://www.borsaitaliana.it", | ||||
| 537 | + "lat": 45.4654, | ||||
| 538 | + "lon": 9.1859, | ||||
| 539 | + "asset_classes": [ | ||||
| 540 | + "EQUITY" | ||||
| 541 | + ], | ||||
| 542 | + "sessions": { | ||||
| 543 | + "regular": [ | ||||
| 544 | + { | ||||
| 545 | + "open": "09:00", | ||||
| 546 | + "close": "17:30" | ||||
| 547 | + } | ||||
| 548 | + ] | ||||
| 549 | + } | ||||
| 550 | + }, | ||||
| 551 | + { | ||||
| 552 | + "id": "xetr", | ||||
| 553 | + "mic": "XETR", | ||||
| 554 | + "name": "Xetra (Frankfurt)", | ||||
| 555 | + "operator": "Deutsche Börse", | ||||
| 556 | + "country": "DE", | ||||
| 557 | + "city": "Frankfurt", | ||||
| 558 | + "timezone": "Europe/Berlin", | ||||
| 559 | + "currency": "EUR", | ||||
| 560 | + "website": "https://www.xetra.com", | ||||
| 561 | + "lat": 50.1155, | ||||
| 562 | + "lon": 8.6742, | ||||
| 563 | + "asset_classes": [ | ||||
| 564 | + "EQUITY", | ||||
| 565 | + "ETF" | ||||
| 566 | + ], | ||||
| 567 | + "sessions": { | ||||
| 568 | + "regular": [ | ||||
| 569 | + { | ||||
| 570 | + "open": "09:00", | ||||
| 571 | + "close": "17:30" | ||||
| 572 | + } | ||||
| 573 | + ] | ||||
| 574 | + } | ||||
| 575 | + }, | ||||
| 576 | + { | ||||
| 577 | + "id": "xswx", | ||||
| 578 | + "mic": "XSWX", | ||||
| 579 | + "name": "SIX Swiss Exchange", | ||||
| 580 | + "operator": "SIX Group", | ||||
| 581 | + "country": "CH", | ||||
| 582 | + "city": "Zurich", | ||||
| 583 | + "timezone": "Europe/Zurich", | ||||
| 584 | + "currency": "CHF", | ||||
| 585 | + "website": "https://www.six-group.com", | ||||
| 586 | + "lat": 47.3667, | ||||
| 587 | + "lon": 8.5378, | ||||
| 588 | + "asset_classes": [ | ||||
| 589 | + "EQUITY", | ||||
| 590 | + "ETF" | ||||
| 591 | + ], | ||||
| 592 | + "sessions": { | ||||
| 593 | + "regular": [ | ||||
| 594 | + { | ||||
| 595 | + "open": "09:00", | ||||
| 596 | + "close": "17:30" | ||||
| 597 | + } | ||||
| 598 | + ] | ||||
| 599 | + } | ||||
| 600 | + }, | ||||
| 601 | + { | ||||
| 602 | + "id": "xwbo", | ||||
| 603 | + "mic": "XWBO", | ||||
| 604 | + "name": "Wiener Börse", | ||||
| 605 | + "operator": "Wiener Börse AG", | ||||
| 606 | + "country": "AT", | ||||
| 607 | + "city": "Vienna", | ||||
| 608 | + "timezone": "Europe/Vienna", | ||||
| 609 | + "currency": "EUR", | ||||
| 610 | + "website": "https://www.wienerborse.at", | ||||
| 611 | + "lat": 48.2116, | ||||
| 612 | + "lon": 16.3702, | ||||
| 613 | + "asset_classes": [ | ||||
| 614 | + "EQUITY" | ||||
| 615 | + ], | ||||
| 616 | + "sessions": { | ||||
| 617 | + "regular": [ | ||||
| 618 | + { | ||||
| 619 | + "open": "09:00", | ||||
| 620 | + "close": "17:30" | ||||
| 621 | + } | ||||
| 622 | + ] | ||||
| 623 | + } | ||||
| 624 | + }, | ||||
| 625 | + { | ||||
| 626 | + "id": "xsto", | ||||
| 627 | + "mic": "XSTO", | ||||
| 628 | + "name": "Nasdaq Stockholm", | ||||
| 629 | + "operator": "Nasdaq Nordic", | ||||
| 630 | + "country": "SE", | ||||
| 631 | + "city": "Stockholm", | ||||
| 632 | + "timezone": "Europe/Stockholm", | ||||
| 633 | + "currency": "SEK", | ||||
| 634 | + "website": "https://www.nasdaqomxnordic.com", | ||||
| 635 | + "lat": 59.33, | ||||
| 636 | + "lon": 18.07, | ||||
| 637 | + "asset_classes": [ | ||||
| 638 | + "EQUITY" | ||||
| 639 | + ], | ||||
| 640 | + "sessions": { | ||||
| 641 | + "regular": [ | ||||
| 642 | + { | ||||
| 643 | + "open": "09:00", | ||||
| 644 | + "close": "17:30" | ||||
| 645 | + } | ||||
| 646 | + ] | ||||
| 647 | + } | ||||
| 648 | + }, | ||||
| 649 | + { | ||||
| 650 | + "id": "xcse", | ||||
| 651 | + "mic": "XCSE", | ||||
| 652 | + "name": "Nasdaq Copenhagen", | ||||
| 653 | + "operator": "Nasdaq Nordic", | ||||
| 654 | + "country": "DK", | ||||
| 655 | + "city": "Copenhagen", | ||||
| 656 | + "timezone": "Europe/Copenhagen", | ||||
| 657 | + "currency": "DKK", | ||||
| 658 | + "website": "https://www.nasdaqomxnordic.com", | ||||
| 659 | + "lat": 55.6761, | ||||
| 660 | + "lon": 12.5683, | ||||
| 661 | + "asset_classes": [ | ||||
| 662 | + "EQUITY" | ||||
| 663 | + ], | ||||
| 664 | + "sessions": { | ||||
| 665 | + "regular": [ | ||||
| 666 | + { | ||||
| 667 | + "open": "09:00", | ||||
| 668 | + "close": "17:00" | ||||
| 669 | + } | ||||
| 670 | + ] | ||||
| 671 | + } | ||||
| 672 | + }, | ||||
| 673 | + { | ||||
| 674 | + "id": "xhel", | ||||
| 675 | + "mic": "XHEL", | ||||
| 676 | + "name": "Nasdaq Helsinki", | ||||
| 677 | + "operator": "Nasdaq Nordic", | ||||
| 678 | + "country": "FI", | ||||
| 679 | + "city": "Helsinki", | ||||
| 680 | + "timezone": "Europe/Helsinki", | ||||
| 681 | + "currency": "EUR", | ||||
| 682 | + "website": "https://www.nasdaqomxnordic.com", | ||||
| 683 | + "lat": 60.1699, | ||||
| 684 | + "lon": 24.9384, | ||||
| 685 | + "asset_classes": [ | ||||
| 686 | + "EQUITY" | ||||
| 687 | + ], | ||||
| 688 | + "sessions": { | ||||
| 689 | + "regular": [ | ||||
| 690 | + { | ||||
| 691 | + "open": "10:00", | ||||
| 692 | + "close": "18:30" | ||||
| 693 | + } | ||||
| 694 | + ] | ||||
| 695 | + } | ||||
| 696 | + }, | ||||
| 697 | + { | ||||
| 698 | + "id": "xosl", | ||||
| 699 | + "mic": "XOSL", | ||||
| 700 | + "name": "Oslo Børs", | ||||
| 701 | + "operator": "Euronext", | ||||
| 702 | + "country": "NO", | ||||
| 703 | + "city": "Oslo", | ||||
| 704 | + "timezone": "Europe/Oslo", | ||||
| 705 | + "currency": "NOK", | ||||
| 706 | + "website": "https://www.euronext.com/en/markets/oslo", | ||||
| 707 | + "lat": 59.9139, | ||||
| 708 | + "lon": 10.7522, | ||||
| 709 | + "asset_classes": [ | ||||
| 710 | + "EQUITY" | ||||
| 711 | + ], | ||||
| 712 | + "sessions": { | ||||
| 713 | + "regular": [ | ||||
| 714 | + { | ||||
| 715 | + "open": "09:00", | ||||
| 716 | + "close": "16:20" | ||||
| 717 | + } | ||||
| 718 | + ] | ||||
| 719 | + } | ||||
| 720 | + }, | ||||
| 721 | + { | ||||
| 722 | + "id": "xwar", | ||||
| 723 | + "mic": "XWAR", | ||||
| 724 | + "name": "Warsaw Stock Exchange", | ||||
| 725 | + "operator": "GPW", | ||||
| 726 | + "country": "PL", | ||||
| 727 | + "city": "Warsaw", | ||||
| 728 | + "timezone": "Europe/Warsaw", | ||||
| 729 | + "currency": "PLN", | ||||
| 730 | + "website": "https://www.gpw.pl", | ||||
| 731 | + "lat": 52.2297, | ||||
| 732 | + "lon": 21.0122, | ||||
| 733 | + "asset_classes": [ | ||||
| 734 | + "EQUITY" | ||||
| 735 | + ], | ||||
| 736 | + "sessions": { | ||||
| 737 | + "regular": [ | ||||
| 738 | + { | ||||
| 739 | + "open": "09:00", | ||||
| 740 | + "close": "17:00" | ||||
| 741 | + } | ||||
| 742 | + ] | ||||
| 743 | + } | ||||
| 744 | + }, | ||||
| 745 | + { | ||||
| 746 | + "id": "xpra", | ||||
| 747 | + "mic": "XPRA", | ||||
| 748 | + "name": "Prague Stock Exchange", | ||||
| 749 | + "operator": "Wiener Börse AG", | ||||
| 750 | + "country": "CZ", | ||||
| 751 | + "city": "Prague", | ||||
| 752 | + "timezone": "Europe/Prague", | ||||
| 753 | + "currency": "CZK", | ||||
| 754 | + "website": "https://www.pse.cz", | ||||
| 755 | + "lat": 50.0755, | ||||
| 756 | + "lon": 14.4378, | ||||
| 757 | + "asset_classes": [ | ||||
| 758 | + "EQUITY" | ||||
| 759 | + ], | ||||
| 760 | + "sessions": { | ||||
| 761 | + "regular": [ | ||||
| 762 | + { | ||||
| 763 | + "open": "09:00", | ||||
| 764 | + "close": "16:20" | ||||
| 765 | + } | ||||
| 766 | + ] | ||||
| 767 | + } | ||||
| 768 | + }, | ||||
| 769 | + { | ||||
| 770 | + "id": "xbud", | ||||
| 771 | + "mic": "XBUD", | ||||
| 772 | + "name": "Budapest Stock Exchange", | ||||
| 773 | + "operator": "BSE", | ||||
| 774 | + "country": "HU", | ||||
| 775 | + "city": "Budapest", | ||||
| 776 | + "timezone": "Europe/Budapest", | ||||
| 777 | + "currency": "HUF", | ||||
| 778 | + "website": "https://www.bse.hu", | ||||
| 779 | + "lat": 47.4979, | ||||
| 780 | + "lon": 19.0402, | ||||
| 781 | + "asset_classes": [ | ||||
| 782 | + "EQUITY" | ||||
| 783 | + ], | ||||
| 784 | + "sessions": { | ||||
| 785 | + "regular": [ | ||||
| 786 | + { | ||||
| 787 | + "open": "09:00", | ||||
| 788 | + "close": "17:00" | ||||
| 789 | + } | ||||
| 790 | + ] | ||||
| 791 | + } | ||||
| 792 | + }, | ||||
| 793 | + { | ||||
| 794 | + "id": "xath", | ||||
| 795 | + "mic": "XATH", | ||||
| 796 | + "name": "Athens Stock Exchange", | ||||
| 797 | + "operator": "ATHEX", | ||||
| 798 | + "country": "GR", | ||||
| 799 | + "city": "Athens", | ||||
| 800 | + "timezone": "Europe/Athens", | ||||
| 801 | + "currency": "EUR", | ||||
| 802 | + "website": "https://www.athexgroup.gr", | ||||
| 803 | + "lat": 37.9838, | ||||
| 804 | + "lon": 23.7275, | ||||
| 805 | + "asset_classes": [ | ||||
| 806 | + "EQUITY" | ||||
| 807 | + ], | ||||
| 808 | + "sessions": { | ||||
| 809 | + "regular": [ | ||||
| 810 | + { | ||||
| 811 | + "open": "10:00", | ||||
| 812 | + "close": "17:20" | ||||
| 813 | + } | ||||
| 814 | + ] | ||||
| 815 | + } | ||||
| 816 | + }, | ||||
| 817 | + { | ||||
| 818 | + "id": "xist", | ||||
| 819 | + "mic": "XIST", | ||||
| 820 | + "name": "Borsa İstanbul", | ||||
| 821 | + "operator": "Borsa İstanbul", | ||||
| 822 | + "country": "TR", | ||||
| 823 | + "city": "Istanbul", | ||||
| 824 | + "timezone": "Europe/Istanbul", | ||||
| 825 | + "currency": "TRY", | ||||
| 826 | + "website": "https://www.borsaistanbul.com", | ||||
| 827 | + "lat": 41.1085, | ||||
| 828 | + "lon": 29.0288, | ||||
| 829 | + "asset_classes": [ | ||||
| 830 | + "EQUITY" | ||||
| 831 | + ], | ||||
| 832 | + "sessions": { | ||||
| 833 | + "regular": [ | ||||
| 834 | + { | ||||
| 835 | + "open": "10:00", | ||||
| 836 | + "close": "18:00" | ||||
| 837 | + } | ||||
| 838 | + ] | ||||
| 839 | + } | ||||
| 840 | + }, | ||||
| 841 | + { | ||||
| 842 | + "id": "xmos", | ||||
| 843 | + "mic": "MISX", | ||||
| 844 | + "name": "Moscow Exchange", | ||||
| 845 | + "operator": "MOEX", | ||||
| 846 | + "country": "RU", | ||||
| 847 | + "city": "Moscow", | ||||
| 848 | + "timezone": "Europe/Moscow", | ||||
| 849 | + "currency": "RUB", | ||||
| 850 | + "website": "https://www.moex.com", | ||||
| 851 | + "lat": 55.7558, | ||||
| 852 | + "lon": 37.6173, | ||||
| 853 | + "asset_classes": [ | ||||
| 854 | + "EQUITY" | ||||
| 855 | + ], | ||||
| 856 | + "sessions": { | ||||
| 857 | + "regular": [ | ||||
| 858 | + { | ||||
| 859 | + "open": "10:00", | ||||
| 860 | + "close": "18:50" | ||||
| 861 | + } | ||||
| 862 | + ] | ||||
| 863 | + } | ||||
| 864 | + }, | ||||
| 865 | + { | ||||
| 866 | + "id": "xtae", | ||||
| 867 | + "mic": "XTAE", | ||||
| 868 | + "name": "Tel Aviv Stock Exchange", | ||||
| 869 | + "operator": "TASE", | ||||
| 870 | + "country": "IL", | ||||
| 871 | + "city": "Tel Aviv", | ||||
| 872 | + "timezone": "Asia/Jerusalem", | ||||
| 873 | + "currency": "ILS", | ||||
| 874 | + "website": "https://www.tase.co.il", | ||||
| 875 | + "lat": 32.0636, | ||||
| 876 | + "lon": 34.7708, | ||||
| 877 | + "asset_classes": [ | ||||
| 878 | + "EQUITY" | ||||
| 879 | + ], | ||||
| 880 | + "sessions": { | ||||
| 881 | + "regular": [ | ||||
| 882 | + { | ||||
| 883 | + "open": "10:00", | ||||
| 884 | + "close": "17:15" | ||||
| 885 | + } | ||||
| 886 | + ], | ||||
| 887 | + "weekdays": [ | ||||
| 888 | + 0, | ||||
| 889 | + 1, | ||||
| 890 | + 2, | ||||
| 891 | + 3, | ||||
| 892 | + 4 | ||||
| 893 | + ] | ||||
| 894 | + } | ||||
| 895 | + }, | ||||
| 896 | + { | ||||
| 897 | + "id": "xsau", | ||||
| 898 | + "mic": "XSAU", | ||||
| 899 | + "name": "Saudi Exchange (Tadawul)", | ||||
| 900 | + "operator": "Saudi Tadawul Group", | ||||
| 901 | + "country": "SA", | ||||
| 902 | + "city": "Riyadh", | ||||
| 903 | + "timezone": "Asia/Riyadh", | ||||
| 904 | + "currency": "SAR", | ||||
| 905 | + "website": "https://www.saudiexchange.sa", | ||||
| 906 | + "lat": 24.7136, | ||||
| 907 | + "lon": 46.6753, | ||||
| 908 | + "asset_classes": [ | ||||
| 909 | + "EQUITY" | ||||
| 910 | + ], | ||||
| 911 | + "sessions": { | ||||
| 912 | + "regular": [ | ||||
| 913 | + { | ||||
| 914 | + "open": "10:00", | ||||
| 915 | + "close": "15:00" | ||||
| 916 | + } | ||||
| 917 | + ], | ||||
| 918 | + "weekdays": [ | ||||
| 919 | + 0, | ||||
| 920 | + 1, | ||||
| 921 | + 2, | ||||
| 922 | + 3, | ||||
| 923 | + 4 | ||||
| 924 | + ] | ||||
| 925 | + } | ||||
| 926 | + }, | ||||
| 927 | + { | ||||
| 928 | + "id": "xdfm", | ||||
| 929 | + "mic": "XDFM", | ||||
| 930 | + "name": "Dubai Financial Market", | ||||
| 931 | + "operator": "DFM", | ||||
| 932 | + "country": "AE", | ||||
| 933 | + "city": "Dubai", | ||||
| 934 | + "timezone": "Asia/Dubai", | ||||
| 935 | + "currency": "AED", | ||||
| 936 | + "website": "https://www.dfm.ae", | ||||
| 937 | + "lat": 25.2172, | ||||
| 938 | + "lon": 55.279, | ||||
| 939 | + "asset_classes": [ | ||||
| 940 | + "EQUITY" | ||||
| 941 | + ], | ||||
| 942 | + "sessions": { | ||||
| 943 | + "regular": [ | ||||
| 944 | + { | ||||
| 945 | + "open": "10:00", | ||||
| 946 | + "close": "14:45" | ||||
| 947 | + } | ||||
| 948 | + ] | ||||
| 949 | + } | ||||
| 950 | + }, | ||||
| 951 | + { | ||||
| 952 | + "id": "dsmd", | ||||
| 953 | + "mic": "DSMD", | ||||
| 954 | + "name": "Qatar Stock Exchange", | ||||
| 955 | + "operator": "QSE", | ||||
| 956 | + "country": "QA", | ||||
| 957 | + "city": "Doha", | ||||
| 958 | + "timezone": "Asia/Qatar", | ||||
| 959 | + "currency": "QAR", | ||||
| 960 | + "website": "https://www.qe.com.qa", | ||||
| 961 | + "lat": 25.2854, | ||||
| 962 | + "lon": 51.531, | ||||
| 963 | + "asset_classes": [ | ||||
| 964 | + "EQUITY" | ||||
| 965 | + ], | ||||
| 966 | + "sessions": { | ||||
| 967 | + "regular": [ | ||||
| 968 | + { | ||||
| 969 | + "open": "09:30", | ||||
| 970 | + "close": "13:15" | ||||
| 971 | + } | ||||
| 972 | + ], | ||||
| 973 | + "weekdays": [ | ||||
| 974 | + 0, | ||||
| 975 | + 1, | ||||
| 976 | + 2, | ||||
| 977 | + 3, | ||||
| 978 | + 4 | ||||
| 979 | + ] | ||||
| 980 | + } | ||||
| 981 | + }, | ||||
| 982 | + { | ||||
| 983 | + "id": "xcai", | ||||
| 984 | + "mic": "XCAI", | ||||
| 985 | + "name": "Egyptian Exchange", | ||||
| 986 | + "operator": "EGX", | ||||
| 987 | + "country": "EG", | ||||
| 988 | + "city": "Cairo", | ||||
| 989 | + "timezone": "Africa/Cairo", | ||||
| 990 | + "currency": "EGP", | ||||
| 991 | + "website": "https://www.egx.com.eg", | ||||
| 992 | + "lat": 30.0444, | ||||
| 993 | + "lon": 31.2357, | ||||
| 994 | + "asset_classes": [ | ||||
| 995 | + "EQUITY" | ||||
| 996 | + ], | ||||
| 997 | + "sessions": { | ||||
| 998 | + "regular": [ | ||||
| 999 | + { | ||||
| 1000 | + "open": "10:00", | ||||
| 1001 | + "close": "14:30" | ||||
| 1002 | + } | ||||
| 1003 | + ], | ||||
| 1004 | + "weekdays": [ | ||||
| 1005 | + 0, | ||||
| 1006 | + 1, | ||||
| 1007 | + 2, | ||||
| 1008 | + 3, | ||||
| 1009 | + 4 | ||||
| 1010 | + ] | ||||
| 1011 | + } | ||||
| 1012 | + }, | ||||
| 1013 | + { | ||||
| 1014 | + "id": "xjse", | ||||
| 1015 | + "mic": "XJSE", | ||||
| 1016 | + "name": "Johannesburg Stock Exchange", | ||||
| 1017 | + "operator": "JSE Limited", | ||||
| 1018 | + "country": "ZA", | ||||
| 1019 | + "city": "Johannesburg", | ||||
| 1020 | + "timezone": "Africa/Johannesburg", | ||||
| 1021 | + "currency": "ZAR", | ||||
| 1022 | + "website": "https://www.jse.co.za", | ||||
| 1023 | + "lat": -26.1076, | ||||
| 1024 | + "lon": 28.0567, | ||||
| 1025 | + "asset_classes": [ | ||||
| 1026 | + "EQUITY" | ||||
| 1027 | + ], | ||||
| 1028 | + "sessions": { | ||||
| 1029 | + "regular": [ | ||||
| 1030 | + { | ||||
| 1031 | + "open": "09:00", | ||||
| 1032 | + "close": "17:00" | ||||
| 1033 | + } | ||||
| 1034 | + ] | ||||
| 1035 | + } | ||||
| 1036 | + }, | ||||
| 1037 | + { | ||||
| 1038 | + "id": "xnsa", | ||||
| 1039 | + "mic": "XNSA", | ||||
| 1040 | + "name": "Nigerian Exchange", | ||||
| 1041 | + "operator": "NGX Group", | ||||
| 1042 | + "country": "NG", | ||||
| 1043 | + "city": "Lagos", | ||||
| 1044 | + "timezone": "Africa/Lagos", | ||||
| 1045 | + "currency": "NGN", | ||||
| 1046 | + "website": "https://ngxgroup.com", | ||||
| 1047 | + "lat": 6.4541, | ||||
| 1048 | + "lon": 3.3947, | ||||
| 1049 | + "asset_classes": [ | ||||
| 1050 | + "EQUITY" | ||||
| 1051 | + ], | ||||
| 1052 | + "sessions": { | ||||
| 1053 | + "regular": [ | ||||
| 1054 | + { | ||||
| 1055 | + "open": "09:30", | ||||
| 1056 | + "close": "14:30" | ||||
| 1057 | + } | ||||
| 1058 | + ] | ||||
| 1059 | + } | ||||
| 1060 | + }, | ||||
| 1061 | + { | ||||
| 1062 | + "id": "xnai", | ||||
| 1063 | + "mic": "XNAI", | ||||
| 1064 | + "name": "Nairobi Securities Exchange", | ||||
| 1065 | + "operator": "NSE", | ||||
| 1066 | + "country": "KE", | ||||
| 1067 | + "city": "Nairobi", | ||||
| 1068 | + "timezone": "Africa/Nairobi", | ||||
| 1069 | + "currency": "KES", | ||||
| 1070 | + "website": "https://www.nse.co.ke", | ||||
| 1071 | + "lat": -1.2921, | ||||
| 1072 | + "lon": 36.8219, | ||||
| 1073 | + "asset_classes": [ | ||||
| 1074 | + "EQUITY" | ||||
| 1075 | + ], | ||||
| 1076 | + "sessions": { | ||||
| 1077 | + "regular": [ | ||||
| 1078 | + { | ||||
| 1079 | + "open": "09:30", | ||||
| 1080 | + "close": "15:00" | ||||
| 1081 | + } | ||||
| 1082 | + ] | ||||
| 1083 | + } | ||||
| 1084 | + }, | ||||
| 1085 | + { | ||||
| 1086 | + "id": "xnse", | ||||
| 1087 | + "mic": "XNSE", | ||||
| 1088 | + "name": "National Stock Exchange of India", | ||||
| 1089 | + "operator": "NSE India", | ||||
| 1090 | + "country": "IN", | ||||
| 1091 | + "city": "Mumbai", | ||||
| 1092 | + "timezone": "Asia/Kolkata", | ||||
| 1093 | + "currency": "INR", | ||||
| 1094 | + "website": "https://www.nseindia.com", | ||||
| 1095 | + "lat": 19.0607, | ||||
| 1096 | + "lon": 72.8611, | ||||
| 1097 | + "asset_classes": [ | ||||
| 1098 | + "EQUITY" | ||||
| 1099 | + ], | ||||
| 1100 | + "sessions": { | ||||
| 1101 | + "regular": [ | ||||
| 1102 | + { | ||||
| 1103 | + "open": "09:15", | ||||
| 1104 | + "close": "15:30" | ||||
| 1105 | + } | ||||
| 1106 | + ] | ||||
| 1107 | + } | ||||
| 1108 | + }, | ||||
| 1109 | + { | ||||
| 1110 | + "id": "xbom", | ||||
| 1111 | + "mic": "XBOM", | ||||
| 1112 | + "name": "BSE (Bombay Stock Exchange)", | ||||
| 1113 | + "operator": "BSE Ltd", | ||||
| 1114 | + "country": "IN", | ||||
| 1115 | + "city": "Mumbai", | ||||
| 1116 | + "timezone": "Asia/Kolkata", | ||||
| 1117 | + "currency": "INR", | ||||
| 1118 | + "website": "https://www.bseindia.com", | ||||
| 1119 | + "lat": 18.9299, | ||||
| 1120 | + "lon": 72.8336, | ||||
| 1121 | + "asset_classes": [ | ||||
| 1122 | + "EQUITY" | ||||
| 1123 | + ], | ||||
| 1124 | + "sessions": { | ||||
| 1125 | + "regular": [ | ||||
| 1126 | + { | ||||
| 1127 | + "open": "09:15", | ||||
| 1128 | + "close": "15:30" | ||||
| 1129 | + } | ||||
| 1130 | + ] | ||||
| 1131 | + } | ||||
| 1132 | + }, | ||||
| 1133 | + { | ||||
| 1134 | + "id": "xshg", | ||||
| 1135 | + "mic": "XSHG", | ||||
| 1136 | + "name": "Shanghai Stock Exchange", | ||||
| 1137 | + "operator": "SSE", | ||||
| 1138 | + "country": "CN", | ||||
| 1139 | + "city": "Shanghai", | ||||
| 1140 | + "timezone": "Asia/Shanghai", | ||||
| 1141 | + "currency": "CNY", | ||||
| 1142 | + "website": "https://www.sse.com.cn", | ||||
| 1143 | + "lat": 31.2304, | ||||
| 1144 | + "lon": 121.4737, | ||||
| 1145 | + "asset_classes": [ | ||||
| 1146 | + "EQUITY" | ||||
| 1147 | + ], | ||||
| 1148 | + "sessions": { | ||||
| 1149 | + "regular": [ | ||||
| 1150 | + { | ||||
| 1151 | + "open": "09:30", | ||||
| 1152 | + "close": "11:30" | ||||
| 1153 | + }, | ||||
| 1154 | + { | ||||
| 1155 | + "open": "13:00", | ||||
| 1156 | + "close": "15:00" | ||||
| 1157 | + } | ||||
| 1158 | + ] | ||||
| 1159 | + } | ||||
| 1160 | + }, | ||||
| 1161 | + { | ||||
| 1162 | + "id": "xshe", | ||||
| 1163 | + "mic": "XSHE", | ||||
| 1164 | + "name": "Shenzhen Stock Exchange", | ||||
| 1165 | + "operator": "SZSE", | ||||
| 1166 | + "country": "CN", | ||||
| 1167 | + "city": "Shenzhen", | ||||
| 1168 | + "timezone": "Asia/Shanghai", | ||||
| 1169 | + "currency": "CNY", | ||||
| 1170 | + "website": "https://www.szse.cn", | ||||
| 1171 | + "lat": 22.5431, | ||||
| 1172 | + "lon": 114.0579, | ||||
| 1173 | + "asset_classes": [ | ||||
| 1174 | + "EQUITY" | ||||
| 1175 | + ], | ||||
| 1176 | + "sessions": { | ||||
| 1177 | + "regular": [ | ||||
| 1178 | + { | ||||
| 1179 | + "open": "09:30", | ||||
| 1180 | + "close": "11:30" | ||||
| 1181 | + }, | ||||
| 1182 | + { | ||||
| 1183 | + "open": "13:00", | ||||
| 1184 | + "close": "15:00" | ||||
| 1185 | + } | ||||
| 1186 | + ] | ||||
| 1187 | + } | ||||
| 1188 | + }, | ||||
| 1189 | + { | ||||
| 1190 | + "id": "xhkg", | ||||
| 1191 | + "mic": "XHKG", | ||||
| 1192 | + "name": "Hong Kong Stock Exchange", | ||||
| 1193 | + "operator": "HKEX", | ||||
| 1194 | + "country": "HK", | ||||
| 1195 | + "city": "Hong Kong", | ||||
| 1196 | + "timezone": "Asia/Hong_Kong", | ||||
| 1197 | + "currency": "HKD", | ||||
| 1198 | + "website": "https://www.hkex.com.hk", | ||||
| 1199 | + "lat": 22.283, | ||||
| 1200 | + "lon": 114.1588, | ||||
| 1201 | + "asset_classes": [ | ||||
| 1202 | + "EQUITY", | ||||
| 1203 | + "ETF" | ||||
| 1204 | + ], | ||||
| 1205 | + "sessions": { | ||||
| 1206 | + "regular": [ | ||||
| 1207 | + { | ||||
| 1208 | + "open": "09:30", | ||||
| 1209 | + "close": "12:00" | ||||
| 1210 | + }, | ||||
| 1211 | + { | ||||
| 1212 | + "open": "13:00", | ||||
| 1213 | + "close": "16:00" | ||||
| 1214 | + } | ||||
| 1215 | + ] | ||||
| 1216 | + } | ||||
| 1217 | + }, | ||||
| 1218 | + { | ||||
| 1219 | + "id": "xtai", | ||||
| 1220 | + "mic": "XTAI", | ||||
| 1221 | + "name": "Taiwan Stock Exchange", | ||||
| 1222 | + "operator": "TWSE", | ||||
| 1223 | + "country": "TW", | ||||
| 1224 | + "city": "Taipei", | ||||
| 1225 | + "timezone": "Asia/Taipei", | ||||
| 1226 | + "currency": "TWD", | ||||
| 1227 | + "website": "https://www.twse.com.tw", | ||||
| 1228 | + "lat": 25.033, | ||||
| 1229 | + "lon": 121.5654, | ||||
| 1230 | + "asset_classes": [ | ||||
| 1231 | + "EQUITY" | ||||
| 1232 | + ], | ||||
| 1233 | + "sessions": { | ||||
| 1234 | + "regular": [ | ||||
| 1235 | + { | ||||
| 1236 | + "open": "09:00", | ||||
| 1237 | + "close": "13:30" | ||||
| 1238 | + } | ||||
| 1239 | + ] | ||||
| 1240 | + } | ||||
| 1241 | + }, | ||||
| 1242 | + { | ||||
| 1243 | + "id": "xkrx", | ||||
| 1244 | + "mic": "XKRX", | ||||
| 1245 | + "name": "Korea Exchange", | ||||
| 1246 | + "operator": "KRX", | ||||
| 1247 | + "country": "KR", | ||||
| 1248 | + "city": "Seoul", | ||||
| 1249 | + "timezone": "Asia/Seoul", | ||||
| 1250 | + "currency": "KRW", | ||||
| 1251 | + "website": "https://global.krx.co.kr", | ||||
| 1252 | + "lat": 37.5236, | ||||
| 1253 | + "lon": 126.926, | ||||
| 1254 | + "asset_classes": [ | ||||
| 1255 | + "EQUITY" | ||||
| 1256 | + ], | ||||
| 1257 | + "sessions": { | ||||
| 1258 | + "regular": [ | ||||
| 1259 | + { | ||||
| 1260 | + "open": "09:00", | ||||
| 1261 | + "close": "15:30" | ||||
| 1262 | + } | ||||
| 1263 | + ] | ||||
| 1264 | + } | ||||
| 1265 | + }, | ||||
| 1266 | + { | ||||
| 1267 | + "id": "xtks", | ||||
| 1268 | + "mic": "XTKS", | ||||
| 1269 | + "name": "Tokyo Stock Exchange", | ||||
| 1270 | + "operator": "Japan Exchange Group", | ||||
| 1271 | + "country": "JP", | ||||
| 1272 | + "city": "Tokyo", | ||||
| 1273 | + "timezone": "Asia/Tokyo", | ||||
| 1274 | + "currency": "JPY", | ||||
| 1275 | + "website": "https://www.jpx.co.jp", | ||||
| 1276 | + "lat": 35.6817, | ||||
| 1277 | + "lon": 139.7785, | ||||
| 1278 | + "asset_classes": [ | ||||
| 1279 | + "EQUITY", | ||||
| 1280 | + "ETF" | ||||
| 1281 | + ], | ||||
| 1282 | + "sessions": { | ||||
| 1283 | + "regular": [ | ||||
| 1284 | + { | ||||
| 1285 | + "open": "09:00", | ||||
| 1286 | + "close": "11:30" | ||||
| 1287 | + }, | ||||
| 1288 | + { | ||||
| 1289 | + "open": "12:30", | ||||
| 1290 | + "close": "15:30" | ||||
| 1291 | + } | ||||
| 1292 | + ] | ||||
| 1293 | + } | ||||
| 1294 | + }, | ||||
| 1295 | + { | ||||
| 1296 | + "id": "xses", | ||||
| 1297 | + "mic": "XSES", | ||||
| 1298 | + "name": "Singapore Exchange", | ||||
| 1299 | + "operator": "SGX Group", | ||||
| 1300 | + "country": "SG", | ||||
| 1301 | + "city": "Singapore", | ||||
| 1302 | + "timezone": "Asia/Singapore", | ||||
| 1303 | + "currency": "SGD", | ||||
| 1304 | + "website": "https://www.sgx.com", | ||||
| 1305 | + "lat": 1.2792, | ||||
| 1306 | + "lon": 103.8507, | ||||
| 1307 | + "asset_classes": [ | ||||
| 1308 | + "EQUITY" | ||||
| 1309 | + ], | ||||
| 1310 | + "sessions": { | ||||
| 1311 | + "regular": [ | ||||
| 1312 | + { | ||||
| 1313 | + "open": "09:00", | ||||
| 1314 | + "close": "12:00" | ||||
| 1315 | + }, | ||||
| 1316 | + { | ||||
| 1317 | + "open": "13:00", | ||||
| 1318 | + "close": "17:00" | ||||
| 1319 | + } | ||||
| 1320 | + ] | ||||
| 1321 | + } | ||||
| 1322 | + }, | ||||
| 1323 | + { | ||||
| 1324 | + "id": "xkls", | ||||
| 1325 | + "mic": "XKLS", | ||||
| 1326 | + "name": "Bursa Malaysia", | ||||
| 1327 | + "operator": "Bursa Malaysia", | ||||
| 1328 | + "country": "MY", | ||||
| 1329 | + "city": "Kuala Lumpur", | ||||
| 1330 | + "timezone": "Asia/Kuala_Lumpur", | ||||
| 1331 | + "currency": "MYR", | ||||
| 1332 | + "website": "https://www.bursamalaysia.com", | ||||
| 1333 | + "lat": 3.149, | ||||
| 1334 | + "lon": 101.6959, | ||||
| 1335 | + "asset_classes": [ | ||||
| 1336 | + "EQUITY" | ||||
| 1337 | + ], | ||||
| 1338 | + "sessions": { | ||||
| 1339 | + "regular": [ | ||||
| 1340 | + { | ||||
| 1341 | + "open": "09:00", | ||||
| 1342 | + "close": "12:30" | ||||
| 1343 | + }, | ||||
| 1344 | + { | ||||
| 1345 | + "open": "14:30", | ||||
| 1346 | + "close": "17:00" | ||||
| 1347 | + } | ||||
| 1348 | + ] | ||||
| 1349 | + } | ||||
| 1350 | + }, | ||||
| 1351 | + { | ||||
| 1352 | + "id": "xbkk", | ||||
| 1353 | + "mic": "XBKK", | ||||
| 1354 | + "name": "Stock Exchange of Thailand", | ||||
| 1355 | + "operator": "SET", | ||||
| 1356 | + "country": "TH", | ||||
| 1357 | + "city": "Bangkok", | ||||
| 1358 | + "timezone": "Asia/Bangkok", | ||||
| 1359 | + "currency": "THB", | ||||
| 1360 | + "website": "https://www.set.or.th", | ||||
| 1361 | + "lat": 13.7563, | ||||
| 1362 | + "lon": 100.5018, | ||||
| 1363 | + "asset_classes": [ | ||||
| 1364 | + "EQUITY" | ||||
| 1365 | + ], | ||||
| 1366 | + "sessions": { | ||||
| 1367 | + "regular": [ | ||||
| 1368 | + { | ||||
| 1369 | + "open": "10:00", | ||||
| 1370 | + "close": "12:30" | ||||
| 1371 | + }, | ||||
| 1372 | + { | ||||
| 1373 | + "open": "14:30", | ||||
| 1374 | + "close": "16:30" | ||||
| 1375 | + } | ||||
| 1376 | + ] | ||||
| 1377 | + } | ||||
| 1378 | + }, | ||||
| 1379 | + { | ||||
| 1380 | + "id": "xidx", | ||||
| 1381 | + "mic": "XIDX", | ||||
| 1382 | + "name": "Indonesia Stock Exchange", | ||||
| 1383 | + "operator": "IDX", | ||||
| 1384 | + "country": "ID", | ||||
| 1385 | + "city": "Jakarta", | ||||
| 1386 | + "timezone": "Asia/Jakarta", | ||||
| 1387 | + "currency": "IDR", | ||||
| 1388 | + "website": "https://www.idx.co.id", | ||||
| 1389 | + "lat": -6.2245, | ||||
| 1390 | + "lon": 106.809, | ||||
| 1391 | + "asset_classes": [ | ||||
| 1392 | + "EQUITY" | ||||
| 1393 | + ], | ||||
| 1394 | + "sessions": { | ||||
| 1395 | + "regular": [ | ||||
| 1396 | + { | ||||
| 1397 | + "open": "09:00", | ||||
| 1398 | + "close": "11:30" | ||||
| 1399 | + }, | ||||
| 1400 | + { | ||||
| 1401 | + "open": "13:30", | ||||
| 1402 | + "close": "15:50" | ||||
| 1403 | + } | ||||
| 1404 | + ] | ||||
| 1405 | + } | ||||
| 1406 | + }, | ||||
| 1407 | + { | ||||
| 1408 | + "id": "xphs", | ||||
| 1409 | + "mic": "XPHS", | ||||
| 1410 | + "name": "Philippine Stock Exchange", | ||||
| 1411 | + "operator": "PSE", | ||||
| 1412 | + "country": "PH", | ||||
| 1413 | + "city": "Manila", | ||||
| 1414 | + "timezone": "Asia/Manila", | ||||
| 1415 | + "currency": "PHP", | ||||
| 1416 | + "website": "https://www.pse.com.ph", | ||||
| 1417 | + "lat": 14.5547, | ||||
| 1418 | + "lon": 121.0244, | ||||
| 1419 | + "asset_classes": [ | ||||
| 1420 | + "EQUITY" | ||||
| 1421 | + ], | ||||
| 1422 | + "sessions": { | ||||
| 1423 | + "regular": [ | ||||
| 1424 | + { | ||||
| 1425 | + "open": "09:30", | ||||
| 1426 | + "close": "12:00" | ||||
| 1427 | + }, | ||||
| 1428 | + { | ||||
| 1429 | + "open": "13:00", | ||||
| 1430 | + "close": "15:00" | ||||
| 1431 | + } | ||||
| 1432 | + ] | ||||
| 1433 | + } | ||||
| 1434 | + }, | ||||
| 1435 | + { | ||||
| 1436 | + "id": "xstc", | ||||
| 1437 | + "mic": "XSTC", | ||||
| 1438 | + "name": "Ho Chi Minh Stock Exchange", | ||||
| 1439 | + "operator": "HOSE", | ||||
| 1440 | + "country": "VN", | ||||
| 1441 | + "city": "Ho Chi Minh City", | ||||
| 1442 | + "timezone": "Asia/Ho_Chi_Minh", | ||||
| 1443 | + "currency": "VND", | ||||
| 1444 | + "website": "https://www.hsx.vn", | ||||
| 1445 | + "lat": 10.7769, | ||||
| 1446 | + "lon": 106.7009, | ||||
| 1447 | + "asset_classes": [ | ||||
| 1448 | + "EQUITY" | ||||
| 1449 | + ], | ||||
| 1450 | + "sessions": { | ||||
| 1451 | + "regular": [ | ||||
| 1452 | + { | ||||
| 1453 | + "open": "09:00", | ||||
| 1454 | + "close": "11:30" | ||||
| 1455 | + }, | ||||
| 1456 | + { | ||||
| 1457 | + "open": "13:00", | ||||
| 1458 | + "close": "15:00" | ||||
| 1459 | + } | ||||
| 1460 | + ] | ||||
| 1461 | + } | ||||
| 1462 | + }, | ||||
| 1463 | + { | ||||
| 1464 | + "id": "xasx", | ||||
| 1465 | + "mic": "XASX", | ||||
| 1466 | + "name": "Australian Securities Exchange", | ||||
| 1467 | + "operator": "ASX Ltd", | ||||
| 1468 | + "country": "AU", | ||||
| 1469 | + "city": "Sydney", | ||||
| 1470 | + "timezone": "Australia/Sydney", | ||||
| 1471 | + "currency": "AUD", | ||||
| 1472 | + "website": "https://www.asx.com.au", | ||||
| 1473 | + "lat": -33.8651, | ||||
| 1474 | + "lon": 151.2099, | ||||
| 1475 | + "asset_classes": [ | ||||
| 1476 | + "EQUITY", | ||||
| 1477 | + "ETF" | ||||
| 1478 | + ], | ||||
| 1479 | + "sessions": { | ||||
| 1480 | + "regular": [ | ||||
| 1481 | + { | ||||
| 1482 | + "open": "10:00", | ||||
| 1483 | + "close": "16:00" | ||||
| 1484 | + } | ||||
| 1485 | + ] | ||||
| 1486 | + } | ||||
| 1487 | + }, | ||||
| 1488 | + { | ||||
| 1489 | + "id": "xnze", | ||||
| 1490 | + "mic": "XNZE", | ||||
| 1491 | + "name": "NZX", | ||||
| 1492 | + "operator": "NZX Limited", | ||||
| 1493 | + "country": "NZ", | ||||
| 1494 | + "city": "Wellington", | ||||
| 1495 | + "timezone": "Pacific/Auckland", | ||||
| 1496 | + "currency": "NZD", | ||||
| 1497 | + "website": "https://www.nzx.com", | ||||
| 1498 | + "lat": -41.2865, | ||||
| 1499 | + "lon": 174.7762, | ||||
| 1500 | + "asset_classes": [ | ||||
| 1501 | + "EQUITY" | ||||
| 1502 | + ], | ||||
| 1503 | + "sessions": { | ||||
| 1504 | + "regular": [ | ||||
| 1505 | + { | ||||
| 1506 | + "open": "10:00", | ||||
| 1507 | + "close": "16:45" | ||||
| 1508 | + } | ||||
| 1509 | + ] | ||||
| 1510 | + } | ||||
| 1511 | + }, | ||||
| 1512 | + { | ||||
| 1513 | + "id": "coinbase", | ||||
| 1514 | + "mic": null, | ||||
| 1515 | + "name": "Coinbase Exchange", | ||||
| 1516 | + "operator": "Coinbase Global", | ||||
| 1517 | + "country": "US", | ||||
| 1518 | + "city": "San Francisco", | ||||
| 1519 | + "timezone": "UTC", | ||||
| 1520 | + "currency": "USD", | ||||
| 1521 | + "website": "https://exchange.coinbase.com", | ||||
| 1522 | + "lat": 37.7749, | ||||
| 1523 | + "lon": -122.4194, | ||||
| 1524 | + "asset_classes": [ | ||||
| 1525 | + "CRYPTO" | ||||
| 1526 | + ], | ||||
| 1527 | + "sessions": { | ||||
| 1528 | + "regular": [], | ||||
| 1529 | + "continuous": true | ||||
| 1530 | + } | ||||
| 1531 | + }, | ||||
| 1532 | + { | ||||
| 1533 | + "id": "kraken", | ||||
| 1534 | + "mic": null, | ||||
| 1535 | + "name": "Kraken", | ||||
| 1536 | + "operator": "Payward, Inc.", | ||||
| 1537 | + "country": "US", | ||||
| 1538 | + "city": "San Francisco", | ||||
| 1539 | + "timezone": "UTC", | ||||
| 1540 | + "currency": "USD", | ||||
| 1541 | + "website": "https://www.kraken.com", | ||||
| 1542 | + "lat": 37.79, | ||||
| 1543 | + "lon": -122.4, | ||||
| 1544 | + "asset_classes": [ | ||||
| 1545 | + "CRYPTO" | ||||
| 1546 | + ], | ||||
| 1547 | + "sessions": { | ||||
| 1548 | + "regular": [], | ||||
| 1549 | + "continuous": true | ||||
| 1550 | + } | ||||
| 1551 | + }, | ||||
| 1552 | + { | ||||
| 1553 | + "id": "binance", | ||||
| 1554 | + "mic": null, | ||||
| 1555 | + "name": "Binance", | ||||
| 1556 | + "operator": "Binance", | ||||
| 1557 | + "country": "XX", | ||||
| 1558 | + "city": null, | ||||
| 1559 | + "timezone": "UTC", | ||||
| 1560 | + "currency": "USDT", | ||||
| 1561 | + "website": "https://www.binance.com", | ||||
| 1562 | + "lat": 43.7384, | ||||
| 1563 | + "lon": 7.4246, | ||||
| 1564 | + "asset_classes": [ | ||||
| 1565 | + "CRYPTO" | ||||
| 1566 | + ], | ||||
| 1567 | + "sessions": { | ||||
| 1568 | + "regular": [], | ||||
| 1569 | + "continuous": true | ||||
| 1570 | + } | ||||
| 1571 | + }, | ||||
| 1572 | + { | ||||
| 1573 | + "id": "okx", | ||||
| 1574 | + "mic": null, | ||||
| 1575 | + "name": "OKX", | ||||
| 1576 | + "operator": "OKX", | ||||
| 1577 | + "country": "XX", | ||||
| 1578 | + "city": null, | ||||
| 1579 | + "timezone": "UTC", | ||||
| 1580 | + "currency": "USDT", | ||||
| 1581 | + "website": "https://www.okx.com", | ||||
| 1582 | + "lat": 1.3521, | ||||
| 1583 | + "lon": 103.8198, | ||||
| 1584 | + "asset_classes": [ | ||||
| 1585 | + "CRYPTO" | ||||
| 1586 | + ], | ||||
| 1587 | + "sessions": { | ||||
| 1588 | + "regular": [], | ||||
| 1589 | + "continuous": true | ||||
| 1590 | + } | ||||
| 1591 | + }, | ||||
| 1592 | + { | ||||
| 1593 | + "id": "bitstamp", | ||||
| 1594 | + "mic": null, | ||||
| 1595 | + "name": "Bitstamp", | ||||
| 1596 | + "operator": "Bitstamp Ltd", | ||||
| 1597 | + "country": "GB", | ||||
| 1598 | + "city": "London", | ||||
| 1599 | + "timezone": "UTC", | ||||
| 1600 | + "currency": "USD", | ||||
| 1601 | + "website": null, | ||||
| 1602 | + "lat": 51.5074, | ||||
| 1603 | + "lon": -0.1278, | ||||
| 1604 | + "asset_classes": [ | ||||
| 1605 | + "CRYPTO" | ||||
| 1606 | + ], | ||||
| 1607 | + "sessions": { | ||||
| 1608 | + "regular": [], | ||||
| 1609 | + "continuous": true | ||||
| 1610 | + } | ||||
| 1611 | + }, | ||||
| 1612 | + { | ||||
| 1613 | + "id": "gemini", | ||||
| 1614 | + "mic": null, | ||||
| 1615 | + "name": "Gemini", | ||||
| 1616 | + "operator": "Gemini Trust Company, LLC", | ||||
| 1617 | + "country": "US", | ||||
| 1618 | + "city": "New York", | ||||
| 1619 | + "timezone": "UTC", | ||||
| 1620 | + "currency": "USD", | ||||
| 1621 | + "website": null, | ||||
| 1622 | + "lat": 40.7484, | ||||
| 1623 | + "lon": -73.9857, | ||||
| 1624 | + "asset_classes": [ | ||||
| 1625 | + "CRYPTO" | ||||
| 1626 | + ], | ||||
| 1627 | + "sessions": { | ||||
| 1628 | + "regular": [], | ||||
| 1629 | + "continuous": true | ||||
| 1630 | + } | ||||
| 1631 | + }, | ||||
| 1632 | + { | ||||
| 1633 | + "id": "bitfinex", | ||||
| 1634 | + "mic": null, | ||||
| 1635 | + "name": "Bitfinex", | ||||
| 1636 | + "operator": "iFinex Inc.", | ||||
| 1637 | + "country": "XX", | ||||
| 1638 | + "city": null, | ||||
| 1639 | + "timezone": "UTC", | ||||
| 1640 | + "currency": "USD", | ||||
| 1641 | + "website": null, | ||||
| 1642 | + "lat": 22.3193, | ||||
| 1643 | + "lon": 114.1694, | ||||
| 1644 | + "asset_classes": [ | ||||
| 1645 | + "CRYPTO" | ||||
| 1646 | + ], | ||||
| 1647 | + "sessions": { | ||||
| 1648 | + "regular": [], | ||||
| 1649 | + "continuous": true | ||||
| 1650 | + } | ||||
| 1651 | + }, | ||||
| 1652 | + { | ||||
| 1653 | + "id": "bybit", | ||||
| 1654 | + "mic": null, | ||||
| 1655 | + "name": "Bybit", | ||||
| 1656 | + "operator": "Bybit", | ||||
| 1657 | + "country": "XX", | ||||
| 1658 | + "city": "Dubai", | ||||
| 1659 | + "timezone": "UTC", | ||||
| 1660 | + "currency": "USD", | ||||
| 1661 | + "website": null, | ||||
| 1662 | + "lat": 25.2048, | ||||
| 1663 | + "lon": 55.2708, | ||||
| 1664 | + "asset_classes": [ | ||||
| 1665 | + "CRYPTO" | ||||
| 1666 | + ], | ||||
| 1667 | + "sessions": { | ||||
| 1668 | + "regular": [], | ||||
| 1669 | + "continuous": true | ||||
| 1670 | + } | ||||
| 1671 | + }, | ||||
| 1672 | + { | ||||
| 1673 | + "id": "gate", | ||||
| 1674 | + "mic": null, | ||||
| 1675 | + "name": "Gate", | ||||
| 1676 | + "operator": "Gate Technology Inc.", | ||||
| 1677 | + "country": "XX", | ||||
| 1678 | + "city": null, | ||||
| 1679 | + "timezone": "UTC", | ||||
| 1680 | + "currency": "USD", | ||||
| 1681 | + "website": null, | ||||
| 1682 | + "lat": 35.1796, | ||||
| 1683 | + "lon": 129.0756, | ||||
| 1684 | + "asset_classes": [ | ||||
| 1685 | + "CRYPTO" | ||||
| 1686 | + ], | ||||
| 1687 | + "sessions": { | ||||
| 1688 | + "regular": [], | ||||
| 1689 | + "continuous": true | ||||
| 1690 | + } | ||||
| 1691 | + }, | ||||
| 1692 | + { | ||||
| 1693 | + "id": "cryptocom", | ||||
| 1694 | + "mic": null, | ||||
| 1695 | + "name": "Crypto.com Exchange", | ||||
| 1696 | + "operator": "Crypto.com", | ||||
| 1697 | + "country": "SG", | ||||
| 1698 | + "city": "Singapore", | ||||
| 1699 | + "timezone": "UTC", | ||||
| 1700 | + "currency": "USD", | ||||
| 1701 | + "website": null, | ||||
| 1702 | + "lat": 1.29, | ||||
| 1703 | + "lon": 103.85, | ||||
| 1704 | + "asset_classes": [ | ||||
| 1705 | + "CRYPTO" | ||||
| 1706 | + ], | ||||
| 1707 | + "sessions": { | ||||
| 1708 | + "regular": [], | ||||
| 1709 | + "continuous": true | ||||
| 1710 | + } | ||||
| 1711 | + }, | ||||
| 1712 | + { | ||||
| 1713 | + "id": "kucoin", | ||||
| 1714 | + "mic": null, | ||||
| 1715 | + "name": "KuCoin", | ||||
| 1716 | + "operator": "KuCoin", | ||||
| 1717 | + "country": "SC", | ||||
| 1718 | + "city": "Victoria", | ||||
| 1719 | + "timezone": "UTC", | ||||
| 1720 | + "currency": "USD", | ||||
| 1721 | + "website": null, | ||||
| 1722 | + "lat": -4.6191, | ||||
| 1723 | + "lon": 55.4513, | ||||
| 1724 | + "asset_classes": [ | ||||
| 1725 | + "CRYPTO" | ||||
| 1726 | + ], | ||||
| 1727 | + "sessions": { | ||||
| 1728 | + "regular": [], | ||||
| 1729 | + "continuous": true | ||||
| 1730 | + } | ||||
| 1731 | + } | ||||
| 1732 | +] | ||||
| \ No newline at end of file | |||||
modified
connectors/src/_shared/crypto.ts
+1 −1
@@ -86,7 +86,7 @@ export function tickerObservations( | |||||
| 86 | const n = num(v); | 86 | const n = num(v); | ||
| 87 | if (n == null) return; | 87 | if (n == null) return; | ||
| 88 | if (field !== "VOLUME" && field !== "BID_SIZE" && field !== "ASK_SIZE" && n <= 0) return; | 88 | if (field !== "VOLUME" && field !== "BID_SIZE" && field !== "ASK_SIZE" && n <= 0) return; | ||
| 89 | − out.push({ symbol, instrumentHint: hint, field, value: n, currency: quote.toUpperCase(), sourceTimestamp: meta.sourceTimestamp, timestampTrust: meta.timestampTrust, sequence: meta.sequence ?? null, rightsStatus: meta.rightsStatus, realtimeStatus: meta.realtimeStatus }); | 89 | + out.push({ symbol, instrumentHint: hint, field, value: n, currency: quote.toUpperCase(), observationType: field === "BID" || field === "ASK" || field === "BID_SIZE" || field === "ASK_SIZE" ? "QUOTE" : "TRADE", sourceTimestamp: meta.sourceTimestamp, timestampTrust: meta.timestampTrust, sequence: meta.sequence ?? null, rightsStatus: meta.rightsStatus, realtimeStatus: meta.realtimeStatus }); | ||
| 90 | }; | 90 | }; | ||
| 91 | push("LAST_PRICE", f.last); | 91 | push("LAST_PRICE", f.last); | ||
| 92 | push("OPEN", f.open); | 92 | push("OPEN", f.open); | ||
added
connectors/src/_shared/pairs.ts
+102 −0
@@ -0,0 +1,102 @@ | |||||
| 1 | +import type { InstrumentHint, NormalizedObservation, ObservationType, RealtimeStatus, RightsStatus, TimestampTrust } from "@market-atlas/market-model"; | ||||
| 2 | +import { cryptoHint, type TickerFields } from "./crypto.js"; | ||||
| 3 | +import { conventionalPair, fxHint } from "../ecb-frankfurter/index.js"; | ||||
| 4 | + | ||||
| 5 | +export const FIAT = new Set(["USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "NZD", "SGD", "HKD", "BRL", "MXN", "TRY", "ZAR", "ARS", "AED", "PLN", "SEK", "NOK", "DKK", "CZK", "HUF", "RON", "UAH", "INR", "KRW", "CNY"]); | ||||
| 6 | +export const STABLES = new Set(["USDT", "USDC", "USD1", "FDUSD", "TUSD", "DAI"]); | ||||
| 7 | + | ||||
| 8 | +export interface PairPlan { | ||||
| 9 | + /** Instrument hint to resolve/create. */ | ||||
| 10 | + hint: InstrumentHint; | ||||
| 11 | + /** Nature of the LAST_PRICE value. */ | ||||
| 12 | + type: ObservationType; | ||||
| 13 | + /** Multiply venue prices by -1 exponent: when true, canonical value = 1 / venue price. */ | ||||
| 14 | + invert: boolean; | ||||
| 15 | + currency: string; | ||||
| 16 | +} | ||||
| 17 | + | ||||
| 18 | +/** | ||||
| 19 | + * Decide what a venue pair *means* for Market Atlas: | ||||
| 20 | + * - fiat/fiat (Kraken EUR/USD, Bitstamp EURUSD) → FOREX instrument, TRADE (a real fiat market) | ||||
| 21 | + * - stable/fiat or fiat/stable (USDT-EUR, EURUSDT) → FOREX instrument, STABLECOIN_PROXY (USDT ≈ USD) | ||||
| 22 | + * - anything else → CRYPTO instrument, TRADE | ||||
| 23 | + */ | ||||
| 24 | +export function planPair(base: string, quote: string, exchangeId: string): PairPlan { | ||||
| 25 | + const b = base.toUpperCase(); | ||||
| 26 | + const q = quote.toUpperCase(); | ||||
| 27 | + if (FIAT.has(b) && FIAT.has(q)) { | ||||
| 28 | + const [cb, cq] = conventionalPair(b, q); | ||||
| 29 | + return { hint: fxHint(cb, cq), type: "TRADE", invert: cb !== b, currency: cq }; | ||||
| 30 | + } | ||||
| 31 | + const bStable = STABLES.has(b); | ||||
| 32 | + const qStable = STABLES.has(q); | ||||
| 33 | + if ((bStable && FIAT.has(q) && q !== "USD") || (qStable && FIAT.has(b) && b !== "USD")) { | ||||
| 34 | + const fb = bStable ? "USD" : b; | ||||
| 35 | + const fq = qStable ? "USD" : q; | ||||
| 36 | + const [cb, cq] = conventionalPair(fb, fq); | ||||
| 37 | + return { hint: fxHint(cb, cq), type: "STABLECOIN_PROXY", invert: cb !== fb, currency: cq }; | ||||
| 38 | + } | ||||
| 39 | + return { hint: cryptoHint(b, q, exchangeId), type: "TRADE", invert: false, currency: q }; | ||||
| 40 | +} | ||||
| 41 | + | ||||
| 42 | +const num = (v: unknown): number | null => { | ||||
| 43 | + if (v == null || v === "") return null; | ||||
| 44 | + const n = typeof v === "number" ? v : Number(v); | ||||
| 45 | + return Number.isFinite(n) ? n : null; | ||||
| 46 | +}; | ||||
| 47 | + | ||||
| 48 | +/** | ||||
| 49 | + * Venue ticker → observations for whatever the pair means (crypto, live FX, stablecoin proxy). | ||||
| 50 | + * Inverted pairs swap bid/ask and drop venue-specific volumes (they would be in the wrong unit). | ||||
| 51 | + */ | ||||
| 52 | +export function pairObservations( | ||||
| 53 | + symbol: string, | ||||
| 54 | + base: string, | ||||
| 55 | + quote: string, | ||||
| 56 | + exchangeId: string, | ||||
| 57 | + f: TickerFields, | ||||
| 58 | + meta: { sourceTimestamp: number | null; timestampTrust: TimestampTrust; sequence?: number | string | null; rightsStatus: RightsStatus; realtimeStatus: RealtimeStatus }, | ||||
| 59 | +): NormalizedObservation[] { | ||||
| 60 | + const plan = planPair(base, quote, exchangeId); | ||||
| 61 | + const out: NormalizedObservation[] = []; | ||||
| 62 | + const conv = (v: number) => (plan.invert ? 1 / v : v); | ||||
| 63 | + const push = (field: NormalizedObservation["field"], v: unknown, type: ObservationType) => { | ||||
| 64 | + const n = num(v); | ||||
| 65 | + if (n == null) return; | ||||
| 66 | + if (field !== "VOLUME" && field !== "BID_SIZE" && field !== "ASK_SIZE" && n <= 0) return; | ||||
| 67 | + const value = field === "VOLUME" || field === "BID_SIZE" || field === "ASK_SIZE" ? n : Number(conv(n).toPrecision(10)); | ||||
| 68 | + out.push({ symbol, instrumentHint: plan.hint, field, value, currency: plan.currency, observationType: type, sourceTimestamp: meta.sourceTimestamp, timestampTrust: meta.timestampTrust, sequence: meta.sequence ?? null, rightsStatus: meta.rightsStatus, realtimeStatus: meta.realtimeStatus, meta: plan.type === "STABLECOIN_PROXY" ? { venue_pair: `${base}/${quote}`, proxy: true } : undefined }); | ||||
| 69 | + }; | ||||
| 70 | + const quoteType: ObservationType = plan.type === "STABLECOIN_PROXY" ? "STABLECOIN_PROXY" : "QUOTE"; | ||||
| 71 | + push("LAST_PRICE", f.last, plan.type); | ||||
| 72 | + if (plan.invert) { | ||||
| 73 | + // 1/x flips the order: venue ask becomes our bid. | ||||
| 74 | + push("BID", f.ask, quoteType); | ||||
| 75 | + push("ASK", f.bid, quoteType); | ||||
| 76 | + push("HIGH", f.low, plan.type); | ||||
| 77 | + push("LOW", f.high, plan.type); | ||||
| 78 | + push("OPEN", f.open, plan.type); | ||||
| 79 | + } else { | ||||
| 80 | + push("BID", f.bid, quoteType); | ||||
| 81 | + push("ASK", f.ask, quoteType); | ||||
| 82 | + push("HIGH", f.high, plan.type); | ||||
| 83 | + push("LOW", f.low, plan.type); | ||||
| 84 | + push("OPEN", f.open, plan.type); | ||||
| 85 | + push("BID_SIZE", f.bidSize, quoteType); | ||||
| 86 | + push("ASK_SIZE", f.askSize, quoteType); | ||||
| 87 | + if (plan.type !== "STABLECOIN_PROXY") push("VOLUME", f.volume, plan.type); | ||||
| 88 | + if (plan.type === "TRADE" && plan.hint.assetClass === "CRYPTO") push("VWAP", f.vwap, plan.type); | ||||
| 89 | + } | ||||
| 90 | + return out; | ||||
| 91 | +} | ||||
| 92 | + | ||||
| 93 | +/** Split a venue symbol using an explicit separator or a list of known quote currencies. */ | ||||
| 94 | +export function splitVenueSymbol(symbol: string, sep: string | null, quotes: string[] = ["USDT", "USDC", "USD", "EUR", "GBP", "JPY", "BTC", "ETH", "TRY", "BRL", "MXN", "ZAR", "ARS", "AUD", "SGD", "AED", "CAD", "CHF"]): [string, string] | null { | ||||
| 95 | + if (sep) { | ||||
| 96 | + const [b, q] = symbol.split(sep); | ||||
| 97 | + return b && q ? [b.toUpperCase(), q.toUpperCase()] : null; | ||||
| 98 | + } | ||||
| 99 | + const s = symbol.toUpperCase(); | ||||
| 100 | + for (const q of quotes.sort((a, b) => b.length - a.length)) if (s.endsWith(q) && s.length > q.length) return [s.slice(0, -q.length), q]; | ||||
| 101 | + return null; | ||||
| 102 | +} | ||||
modified
connectors/src/bank-of-canada/index.ts
+3 −3
@@ -81,11 +81,11 @@ export const bankOfCanada = defineConnector({ | |||||
| 81 | const foreign = sid.slice(2, 5); | 81 | const foreign = sid.slice(2, 5); | ||
| 82 | const [b, q] = conventionalPair(foreign, "CAD"); | 82 | const [b, q] = conventionalPair(foreign, "CAD"); | ||
| 83 | const v = b === foreign ? value : 1 / value; // series is CAD per 1 unit of foreign | 83 | const v = b === foreign ? value : 1 / value; // series is CAD per 1 unit of foreign | ||
| 84 | − observations.push({ symbol: `${b}${q}`, instrumentHint: fxHint(b, q), field: field === "RATE" || field === "YIELD" ? "LAST_PRICE" : field, value: Number(v.toPrecision(8)), currency: q, sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date } }); | 84 | + observations.push({ symbol: `${b}${q}`, instrumentHint: fxHint(b, q), field: field === "RATE" || field === "YIELD" ? "LAST_PRICE" : field, value: Number(v.toPrecision(8)), currency: q, observationType: "OFFICIAL_FIX", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date } }); | ||
| 85 | } else if (r.kind === "rates" && RATE_SERIES[sid]) { | 85 | } else if (r.kind === "rates" && RATE_SERIES[sid]) { | ||
| 86 | const s = RATE_SERIES[sid]!; | 86 | const s = RATE_SERIES[sid]!; | ||
| 87 | − observations.push({ symbol: s.symbol, instrumentHint: rateHint(s), field: field === "PREVIOUS_CLOSE" ? "PREVIOUS_CLOSE" : s.field, value, currency: "CAD", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date, label: p.seriesDetail?.[sid]?.label } }); | 87 | + observations.push({ symbol: s.symbol, instrumentHint: rateHint(s), field: field === "PREVIOUS_CLOSE" ? "PREVIOUS_CLOSE" : s.field, value, currency: "CAD", observationType: "REFERENCE_RATE", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date, label: p.seriesDetail?.[sid]?.label } }); | ||
| 88 | − if (field !== "PREVIOUS_CLOSE") observations.push({ symbol: s.symbol, instrumentHint: rateHint(s), field: "LAST_PRICE", value, currency: "CAD", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date } }); | 88 | + if (field !== "PREVIOUS_CLOSE") observations.push({ symbol: s.symbol, instrumentHint: rateHint(s), field: "LAST_PRICE", value, currency: "CAD", observationType: "REFERENCE_RATE", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date } }); | ||
| 89 | } | 89 | } | ||
| 90 | }; | 90 | }; | ||
| 91 | emit(last, "LAST_PRICE"); | 91 | emit(last, "LAST_PRICE"); | ||
modified
connectors/src/binance-ws/index.ts
+9 −5
@@ -1,8 +1,12 @@ | |||||
| 1 | import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | 1 | import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | ||
| 2 | import { parseTimestamp } from "@market-atlas/market-model"; | 2 | import { parseTimestamp } from "@market-atlas/market-model"; | ||
| 3 | −import { MAJOR_BASES, cryptoSeeds, tickerObservations } from "../_shared/crypto.js"; | 3 | +import { MAJOR_BASES, cryptoSeeds } from "../_shared/crypto.js"; | ||
| 4 | +import { pairObservations } from "../_shared/pairs.js"; | ||||
| 4 | 5 | ||||
| 5 | −const PAIRS: Array<[string, string]> = MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USDT"] as [string, string]).concat([["BNB", "USDT"], ["TRX", "USDT"], ["PAXG", "USDT"]]); | 6 | +const CRYPTO_PAIRS: Array<[string, string]> = MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USDT"] as [string, string]).concat([["BNB", "USDT"], ["TRX", "USDT"], ["PAXG", "USDT"]]); | ||
| 7 | +/** Fiat/stablecoin markets → live FX proxies (USDT ≈ USD), labelled STABLECOIN_PROXY. */ | ||||
| 8 | +const FX_PROXY_PAIRS: Array<[string, string]> = [["EUR", "USDT"], ["USDT", "BRL"], ["USDT", "MXN"], ["USDT", "TRY"], ["USDT", "ZAR"], ["USDT", "ARS"]]; | ||||
| 9 | +const PAIRS = [...CRYPTO_PAIRS, ...FX_PROXY_PAIRS]; | ||||
| 6 | const SYMBOLS = PAIRS.map(([b, q]) => `${b}${q}`); | 10 | const SYMBOLS = PAIRS.map(([b, q]) => `${b}${q}`); | ||
| 7 | const streamUrl = (symbols: string[]) => `wss://stream.binance.com:9443/stream?streams=${symbols.map((s) => `${s.toLowerCase()}@miniTicker`).join("/")}`; | 11 | const streamUrl = (symbols: string[]) => `wss://stream.binance.com:9443/stream?streams=${symbols.map((s) => `${s.toLowerCase()}@miniTicker`).join("/")}`; | ||
| 8 | 12 | ||||
@@ -21,7 +25,7 @@ export const binanceWs = defineConnector({ | |||||
| 21 | expectedLatencyMs: 1000, | 25 | expectedLatencyMs: 1000, | ||
| 22 | supportsStreaming: true, | 26 | supportsStreaming: true, | ||
| 23 | supportsHistorical: false, | 27 | supportsHistorical: false, | ||
| 24 | − assetClasses: ["CRYPTO"], | 28 | + assetClasses: ["CRYPTO", "FOREX"], | ||
| 25 | exchanges: ["binance"], | 29 | exchanges: ["binance"], | ||
| 26 | homepage: "https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams", | 30 | homepage: "https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams", | ||
| 27 | description: "Public Binance spot combined WebSocket stream (miniTicker per symbol, pushed every second) for USDT-quoted majors. Exchange event time (E) is used as source timestamp.", | 31 | description: "Public Binance spot combined WebSocket stream (miniTicker per symbol, pushed every second) for USDT-quoted majors. Exchange event time (E) is used as source timestamp.", | ||
@@ -30,7 +34,7 @@ export const binanceWs = defineConnector({ | |||||
| 30 | sourceFamily: "binance", | 34 | sourceFamily: "binance", | ||
| 31 | enabled: true, | 35 | enabled: true, | ||
| 32 | }, | 36 | }, | ||
| 33 | − seeds: cryptoSeeds(PAIRS, "binance", (b, q) => `${b}${q}`), | 37 | + seeds: cryptoSeeds(CRYPTO_PAIRS, "binance", (b, q) => `${b}${q}`), | ||
| 34 | defaultSymbols: SYMBOLS, | 38 | defaultSymbols: SYMBOLS, | ||
| 35 | async start(ctx) { | 39 | async start(ctx) { | ||
| 36 | const ws = ctx.openWebSocket(streamUrl(ctx.watchedSymbols()), { | 40 | const ws = ctx.openWebSocket(streamUrl(ctx.watchedSymbols()), { | ||
@@ -57,7 +61,7 @@ export const binanceWs = defineConnector({ | |||||
| 57 | const quote = pair?.[1] ?? (m.s.endsWith("USDT") ? "USDT" : null); | 61 | const quote = pair?.[1] ?? (m.s.endsWith("USDT") ? "USDT" : null); | ||
| 58 | if (!base || !quote) return { observations: [] }; | 62 | if (!base || !quote) return { observations: [] }; | ||
| 59 | return { | 63 | return { | ||
| 60 | − observations: tickerObservations(m.s, base, quote, "binance", { last: m.c, open: m.o, high: m.h, low: m.l, volume: m.v }, { | 64 | + observations: pairObservations(m.s, base, quote, "binance", { last: m.c, open: m.o, high: m.h, low: m.l, volume: m.v }, { | ||
| 61 | sourceTimestamp: parseTimestamp(m.E), | 65 | sourceTimestamp: parseTimestamp(m.E), | ||
| 62 | timestampTrust: "EXCHANGE", | 66 | timestampTrust: "EXCHANGE", | ||
| 63 | rightsStatus: "PUBLIC_ATTRIBUTED", | 67 | rightsStatus: "PUBLIC_ATTRIBUTED", | ||
added
connectors/src/bitfinex-ws/README.md
+7 −0
@@ -0,0 +1,7 @@ | |||||
| 1 | +# bitfinex-ws | ||||
| 2 | + | ||||
| 3 | +Bitfinex public WebSocket v2 (`wss://api-pub.bitfinex.com/ws/2`), `ticker` channel. | ||||
| 4 | + | ||||
| 5 | +- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (no per-frame timestamp → CONNECTOR trust) | ||||
| 6 | +- **Frames**: `[chanId, [BID, BID_SIZE, ASK, ASK_SIZE, DAILY_CHANGE, DAILY_CHANGE_REL, LAST, VOLUME, HIGH, LOW]]`; `hb` heartbeats ignored. The `subscribed` event maps `chanId → symbol` (kept per connection). | ||||
| 7 | +- **Pairs**: USD & USDT (`UST`) crypto majors, BTC/EUR, EUR/USDT (STABLECOIN_PROXY; Bitfinex has no fiat/fiat spot markets). Symbols longer than 3 letters use the `t<BASE>:<QUOTE>` form (e.g. `tDOGE:USD`). | ||||
added
connectors/src/bitfinex-ws/fixtures/ticker.json
+1 −0
@@ -0,0 +1 @@ | |||||
| 1 | +{"symbol":"tBTCUSD","ticker":[77123,3.44004551,77142,1.58754094,-106,-0.00137263,77118,1639.7559031,77442,76979]} | ||||
added
connectors/src/bitfinex-ws/index.ts
+94 −0
@@ -0,0 +1,94 @@ | |||||
| 1 | +import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | ||||
| 2 | +import { pairObservations, planPair } from "../_shared/pairs.js"; | ||||
| 3 | + | ||||
| 4 | +const WS_URL = "wss://api-pub.bitfinex.com/ws/2"; | ||||
| 5 | +/** Bitfinex symbol → [base, quote] (UST = USDT on Bitfinex). */ | ||||
| 6 | +const PAIRS: Record<string, [string, string]> = { | ||||
| 7 | + tBTCUSD: ["BTC", "USD"], | ||||
| 8 | + tETHUSD: ["ETH", "USD"], | ||||
| 9 | + tSOLUSD: ["SOL", "USD"], | ||||
| 10 | + tXRPUSD: ["XRP", "USD"], | ||||
| 11 | + tLTCUSD: ["LTC", "USD"], | ||||
| 12 | + tADAUSD: ["ADA", "USD"], | ||||
| 13 | + "tDOGE:USD": ["DOGE", "USD"], | ||||
| 14 | + tDOTUSD: ["DOT", "USD"], | ||||
| 15 | + "tAVAX:USD": ["AVAX", "USD"], | ||||
| 16 | + "tLINK:USD": ["LINK", "USD"], | ||||
| 17 | + tBTCUST: ["BTC", "USDT"], | ||||
| 18 | + tETHUST: ["ETH", "USDT"], | ||||
| 19 | + tBTCEUR: ["BTC", "EUR"], | ||||
| 20 | + tEURUST: ["EUR", "USDT"], | ||||
| 21 | +}; | ||||
| 22 | + | ||||
| 23 | +/** Bitfinex public WebSocket v2, `ticker` channel (array frames; channel id → symbol mapping kept per connection). */ | ||||
| 24 | +export const bitfinexWs = defineConnector({ | ||||
| 25 | + metadata: { | ||||
| 26 | + id: "bitfinex-ws", | ||||
| 27 | + name: "Bitfinex — ticker channel (WS v2)", | ||||
| 28 | + version: "1.0.0", | ||||
| 29 | + sourceId: "bitfinex", | ||||
| 30 | + organization: "iFinex Inc.", | ||||
| 31 | + sourceType: "WEBSOCKET", | ||||
| 32 | + jurisdiction: null, | ||||
| 33 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 34 | + realtimeStatus: "REALTIME", | ||||
| 35 | + expectedLatencyMs: 500, | ||||
| 36 | + supportsStreaming: true, | ||||
| 37 | + supportsHistorical: false, | ||||
| 38 | + assetClasses: ["CRYPTO", "FOREX"], | ||||
| 39 | + exchanges: ["bitfinex"], | ||||
| 40 | + homepage: "https://docs.bitfinex.com/reference/ws-public-ticker", | ||||
| 41 | + description: "Public Bitfinex WebSocket v2 ticker channel (bid/ask with sizes, last price, 24h volume/high/low) for major USD and USDT crypto pairs, plus the EUR/USDT stablecoin market (live FX proxy).", | ||||
| 42 | + rightsNotes: "Public market data displayed with attribution to Bitfinex.", | ||||
| 43 | + termsUrl: "https://www.bitfinex.com/legal/general/terms", | ||||
| 44 | + sourceFamily: "bitfinex", | ||||
| 45 | + enabled: true, | ||||
| 46 | + }, | ||||
| 47 | + seeds: Object.entries(PAIRS).map(([sym, [b, q]]) => ({ symbol: sym, hint: planPair(b, q, "bitfinex").hint, aliases: [`${b}-${q}`, `${b}/${q}`] })), | ||||
| 48 | + defaultSymbols: Object.keys(PAIRS), | ||||
| 49 | + async start(ctx) { | ||||
| 50 | + const channels = new Map<number, string>(); | ||||
| 51 | + const ws = ctx.openWebSocket(WS_URL, { | ||||
| 52 | + label: "bitfinex", | ||||
| 53 | + staleAfterMs: 90_000, | ||||
| 54 | + heartbeat: { intervalMs: 25_000, message: JSON.stringify({ event: "ping", cid: 1 }) }, | ||||
| 55 | + onOpen: (sock) => { | ||||
| 56 | + channels.clear(); | ||||
| 57 | + for (const s of ctx.watchedSymbols()) sock.send({ event: "subscribe", channel: "ticker", symbol: s }); | ||||
| 58 | + }, | ||||
| 59 | + onMessage: (data) => { | ||||
| 60 | + let msg: any; | ||||
| 61 | + try { | ||||
| 62 | + msg = JSON.parse(data); | ||||
| 63 | + } catch { | ||||
| 64 | + return; | ||||
| 65 | + } | ||||
| 66 | + if (Array.isArray(msg)) { | ||||
| 67 | + const [chanId, body] = msg; | ||||
| 68 | + if (body === "hb" || !Array.isArray(body)) return; | ||||
| 69 | + const symbol = channels.get(chanId); | ||||
| 70 | + if (symbol) ctx.emit(raw("bitfinex-ws", "bitfinex", "ticker", { symbol, ticker: body })); | ||||
| 71 | + } else if (msg?.event === "subscribed" && msg.channel === "ticker") channels.set(msg.chanId, msg.symbol); | ||||
| 72 | + else if (msg?.event === "error") ctx.reportError(new Error(String(msg.msg ?? "bitfinex error")), { code: msg.code, symbol: msg.symbol }); | ||||
| 73 | + }, | ||||
| 74 | + }); | ||||
| 75 | + ws.connect(); | ||||
| 76 | + }, | ||||
| 77 | + normalize(r): NormalizedBatch { | ||||
| 78 | + const m = r.payload as { symbol?: string; ticker?: unknown[] }; | ||||
| 79 | + if (r.kind !== "ticker" || typeof m.symbol !== "string" || !Array.isArray(m.ticker) || m.ticker.length < 10) return { observations: [] }; | ||||
| 80 | + const pair = PAIRS[m.symbol]; | ||||
| 81 | + if (!pair) return { observations: [] }; | ||||
| 82 | + // [BID, BID_SIZE, ASK, ASK_SIZE, DAILY_CHANGE, DAILY_CHANGE_RELATIVE, LAST_PRICE, VOLUME, HIGH, LOW] | ||||
| 83 | + const [bid, bidSize, ask, askSize, , , last, volume, high, low] = m.ticker; | ||||
| 84 | + return { | ||||
| 85 | + observations: pairObservations(m.symbol, pair[0], pair[1], "bitfinex", { bid, bidSize, ask, askSize, last, volume, high, low }, { | ||||
| 86 | + sourceTimestamp: null, | ||||
| 87 | + timestampTrust: "CONNECTOR", | ||||
| 88 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 89 | + realtimeStatus: "REALTIME", | ||||
| 90 | + }), | ||||
| 91 | + }; | ||||
| 92 | + }, | ||||
| 93 | + fixturesDir: "fixtures", | ||||
| 94 | +}); | ||||
added
connectors/src/bitstamp-ws/README.md
+8 −0
@@ -0,0 +1,8 @@ | |||||
| 1 | +# bitstamp-ws | ||||
| 2 | + | ||||
| 3 | +Bitstamp public WebSocket v2 (`wss://ws.bitstamp.net`): `live_trades_<pair>` + `order_book_<pair>`. | ||||
| 4 | + | ||||
| 5 | +- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (exchange `microtimestamp`) | ||||
| 6 | +- **Pairs**: BTC/ETH/XRP/LTC/SOL/ADA/LINK/DOGE in USD, BTC/ETH in EUR, BTC/USDT, plus **EUR/USD and GBP/USD** (real fiat spot markets → FOREX instruments, observation type TRADE). | ||||
| 7 | +- **Fields**: LAST_PRICE from trades, BID/ASK from the top of the book. No 24 h stats on these channels. | ||||
| 8 | +- **Heartbeat**: `{"event":"bts:heartbeat"}` every 25 s; the server may send `bts:request_reconnect` (handled by the SDK reconnect on close). | ||||
added
connectors/src/bitstamp-ws/fixtures/book.json
+1 −0
@@ -0,0 +1 @@ | |||||
| 1 | +{"data":{"timestamp":"1789273907","microtimestamp":"1789273907518784","bids":[["1.17252","1200.00"],["1.17250","5000.00"]],"asks":[["1.17261","800.00"],["1.17265","2500.00"]]},"channel":"order_book_eurusd","event":"data"} | ||||
added
connectors/src/bitstamp-ws/fixtures/trade.json
+1 −0
@@ -0,0 +1 @@ | |||||
| 1 | +{"data":{"id":634456427,"timestamp":"1789273894","amount":0.000154,"amount_str":"0.00015400","price":77203.59,"price_str":"77203.59","type":1,"microtimestamp":"1789273894413000","buy_order_id":2049797299200000,"sell_order_id":2049797815320577},"channel":"live_trades_btcusd","event":"trade"} | ||||
added
connectors/src/bitstamp-ws/index.ts
+96 −0
@@ -0,0 +1,96 @@ | |||||
| 1 | +import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | ||||
| 2 | +import type { NormalizedObservation } from "@market-atlas/market-model"; | ||||
| 3 | +import { pairObservations, planPair } from "../_shared/pairs.js"; | ||||
| 4 | + | ||||
| 5 | +const WS_URL = "wss://ws.bitstamp.net"; | ||||
| 6 | +/** Bitstamp url_symbol → [base, quote]. Includes the two real fiat markets (EUR/USD, GBP/USD). */ | ||||
| 7 | +const PAIRS: Record<string, [string, string]> = { | ||||
| 8 | + btcusd: ["BTC", "USD"], | ||||
| 9 | + ethusd: ["ETH", "USD"], | ||||
| 10 | + xrpusd: ["XRP", "USD"], | ||||
| 11 | + ltcusd: ["LTC", "USD"], | ||||
| 12 | + solusd: ["SOL", "USD"], | ||||
| 13 | + adausd: ["ADA", "USD"], | ||||
| 14 | + linkusd: ["LINK", "USD"], | ||||
| 15 | + dogeusd: ["DOGE", "USD"], | ||||
| 16 | + btceur: ["BTC", "EUR"], | ||||
| 17 | + etheur: ["ETH", "EUR"], | ||||
| 18 | + btcusdt: ["BTC", "USDT"], | ||||
| 19 | + eurusd: ["EUR", "USD"], | ||||
| 20 | + gbpusd: ["GBP", "USD"], | ||||
| 21 | +}; | ||||
| 22 | + | ||||
| 23 | +/** Bitstamp public WebSocket v2: live trades (price) + top of the order book (bid/ask) per pair. */ | ||||
| 24 | +export const bitstampWs = defineConnector({ | ||||
| 25 | + metadata: { | ||||
| 26 | + id: "bitstamp-ws", | ||||
| 27 | + name: "Bitstamp — live trades & order book", | ||||
| 28 | + version: "1.0.0", | ||||
| 29 | + sourceId: "bitstamp", | ||||
| 30 | + organization: "Bitstamp Ltd", | ||||
| 31 | + sourceType: "WEBSOCKET", | ||||
| 32 | + jurisdiction: "GB", | ||||
| 33 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 34 | + realtimeStatus: "REALTIME", | ||||
| 35 | + expectedLatencyMs: 300, | ||||
| 36 | + supportsStreaming: true, | ||||
| 37 | + supportsHistorical: false, | ||||
| 38 | + assetClasses: ["CRYPTO", "FOREX"], | ||||
| 39 | + exchanges: ["bitstamp"], | ||||
| 40 | + homepage: "https://www.bitstamp.net/websocket/v2/", | ||||
| 41 | + description: "Public Bitstamp WebSocket v2: `live_trades_<pair>` (last trade, exchange microtimestamp) and `order_book_<pair>` (best bid/ask) for the major USD/EUR crypto markets and Bitstamp's fiat markets EUR/USD and GBP/USD.", | ||||
| 42 | + rightsNotes: "Public market data displayed with attribution to Bitstamp.", | ||||
| 43 | + termsUrl: "https://www.bitstamp.net/terms-of-use/", | ||||
| 44 | + sourceFamily: "bitstamp", | ||||
| 45 | + enabled: true, | ||||
| 46 | + }, | ||||
| 47 | + seeds: Object.entries(PAIRS).map(([sym, [b, q]]) => ({ symbol: sym, hint: planPair(b, q, "bitstamp").hint, aliases: [`${b}-${q}`, `${b}/${q}`] })), | ||||
| 48 | + defaultSymbols: Object.keys(PAIRS), | ||||
| 49 | + async start(ctx) { | ||||
| 50 | + const ws = ctx.openWebSocket(WS_URL, { | ||||
| 51 | + label: "bitstamp", | ||||
| 52 | + staleAfterMs: 120_000, | ||||
| 53 | + heartbeat: { intervalMs: 25_000, message: JSON.stringify({ event: "bts:heartbeat" }) }, | ||||
| 54 | + onOpen: (sock) => { | ||||
| 55 | + for (const p of ctx.watchedSymbols()) { | ||||
| 56 | + sock.send({ event: "bts:subscribe", data: { channel: `live_trades_${p}` } }); | ||||
| 57 | + sock.send({ event: "bts:subscribe", data: { channel: `order_book_${p}` } }); | ||||
| 58 | + } | ||||
| 59 | + }, | ||||
| 60 | + onMessage: (data) => { | ||||
| 61 | + let msg: any; | ||||
| 62 | + try { | ||||
| 63 | + msg = JSON.parse(data); | ||||
| 64 | + } catch { | ||||
| 65 | + return; | ||||
| 66 | + } | ||||
| 67 | + if (msg?.event === "trade") ctx.emit(raw("bitstamp-ws", "bitstamp", "trade", msg)); | ||||
| 68 | + else if (msg?.event === "data" && typeof msg.channel === "string" && msg.channel.startsWith("order_book_")) ctx.emit(raw("bitstamp-ws", "bitstamp", "book", msg)); | ||||
| 69 | + else if (msg?.event === "bts:request_reconnect") ctx.logger.info("bitstamp asked to reconnect"); | ||||
| 70 | + }, | ||||
| 71 | + }); | ||||
| 72 | + ws.connect(); | ||||
| 73 | + }, | ||||
| 74 | + normalize(r): NormalizedBatch { | ||||
| 75 | + const m = r.payload as { channel?: string; data?: Record<string, unknown> }; | ||||
| 76 | + const pairKey = typeof m.channel === "string" ? m.channel.replace(/^(live_trades_|order_book_)/, "") : ""; | ||||
| 77 | + const pair = PAIRS[pairKey]; | ||||
| 78 | + if (!pair || !m.data) return { observations: [] }; | ||||
| 79 | + const [base, quote] = pair; | ||||
| 80 | + const micro = m.data.microtimestamp; | ||||
| 81 | + const ts = typeof micro === "string" && /^\d+$/.test(micro) ? Math.floor(Number(micro) / 1000) : null; | ||||
| 82 | + const meta = { sourceTimestamp: ts, timestampTrust: "EXCHANGE" as const, rightsStatus: "PUBLIC_ATTRIBUTED" as const, realtimeStatus: "REALTIME" as const }; | ||||
| 83 | + let observations: NormalizedObservation[] = []; | ||||
| 84 | + if (r.kind === "trade") { | ||||
| 85 | + observations = pairObservations(pairKey, base, quote, "bitstamp", { last: m.data.price }, { ...meta, sequence: typeof m.data.id === "number" ? m.data.id : null }); | ||||
| 86 | + } else if (r.kind === "book") { | ||||
| 87 | + const bids = m.data.bids as unknown; | ||||
| 88 | + const asks = m.data.asks as unknown; | ||||
| 89 | + const bid = Array.isArray(bids) && Array.isArray(bids[0]) ? bids[0][0] : undefined; | ||||
| 90 | + const ask = Array.isArray(asks) && Array.isArray(asks[0]) ? asks[0][0] : undefined; | ||||
| 91 | + observations = pairObservations(pairKey, base, quote, "bitstamp", { bid, ask }, meta); | ||||
| 92 | + } | ||||
| 93 | + return { observations }; | ||||
| 94 | + }, | ||||
| 95 | + fixturesDir: "fixtures", | ||||
| 96 | +}); | ||||
added
connectors/src/bybit-ws/README.md
+8 −0
@@ -0,0 +1,8 @@ | |||||
| 1 | +# bybit-ws | ||||
| 2 | + | ||||
| 3 | +Bybit v5 public spot stream (`wss://stream.bybit.com/v5/public/spot`), topics `tickers.<SYMBOL>`. | ||||
| 4 | + | ||||
| 5 | +- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (frame `ts`) | ||||
| 6 | +- **Fields**: LAST_PRICE, HIGH/LOW (24 h), OPEN (= `prevPrice24h`), VOLUME (24 h). Bid/ask are not in this topic. | ||||
| 7 | +- **Heartbeat**: client `{"op":"ping"}` every 20 s. | ||||
| 8 | +- **Pairs**: USDT majors + EURUSDT / USDTBRL / USDTTRY (FX proxies). | ||||
added
connectors/src/bybit-ws/fixtures/ticker.json
+1 −0
@@ -0,0 +1 @@ | |||||
| 1 | +{"topic":"tickers.BTCUSDT","ts":1789273910127,"type":"snapshot","cs":113941251526,"data":{"symbol":"BTCUSDT","lastPrice":"77229.4","highPrice24h":"77511.8","lowPrice24h":"77053.1","prevPrice24h":"77279.6","volume24h":"1891.389084","turnover24h":"146169585.1767","price24hPcnt":"-0.0006","usdIndexPrice":"77235.12"}} | ||||
added
connectors/src/bybit-ws/index.ts
+79 −0
@@ -0,0 +1,79 @@ | |||||
| 1 | +import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | ||||
| 2 | +import { parseTimestamp } from "@market-atlas/market-model"; | ||||
| 3 | +import { MAJOR_BASES } from "../_shared/crypto.js"; | ||||
| 4 | +import { pairObservations, planPair, splitVenueSymbol } from "../_shared/pairs.js"; | ||||
| 5 | + | ||||
| 6 | +const WS_URL = "wss://stream.bybit.com/v5/public/spot"; | ||||
| 7 | +const SYMBOLS = [...MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => `${b}USDT`), "USDTEUR", "USDCEUR", "USDTBRL", "USDTTRY", "USDTAED"]; | ||||
| 8 | + | ||||
| 9 | +/** Bybit v5 public spot stream, `tickers.<symbol>` (snapshot + deltas with the full last price). Ping `{"op":"ping"}` every 20 s. */ | ||||
| 10 | +export const bybitWs = defineConnector({ | ||||
| 11 | + metadata: { | ||||
| 12 | + id: "bybit-ws", | ||||
| 13 | + name: "Bybit — spot tickers (v5)", | ||||
| 14 | + version: "1.0.0", | ||||
| 15 | + sourceId: "bybit", | ||||
| 16 | + organization: "Bybit", | ||||
| 17 | + sourceType: "WEBSOCKET", | ||||
| 18 | + jurisdiction: null, | ||||
| 19 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 20 | + realtimeStatus: "REALTIME", | ||||
| 21 | + expectedLatencyMs: 500, | ||||
| 22 | + supportsStreaming: true, | ||||
| 23 | + supportsHistorical: false, | ||||
| 24 | + assetClasses: ["CRYPTO", "FOREX"], | ||||
| 25 | + exchanges: ["bybit"], | ||||
| 26 | + homepage: "https://bybit-exchange.github.io/docs/v5/websocket/public/ticker", | ||||
| 27 | + description: "Public Bybit v5 spot ticker stream (last price, 24h high/low/volume, previous 24h price) for USDT majors and the USDT/EUR, USDC/EUR, USDT/BRL, USDT/TRY, USDT/AED stablecoin markets (live FX proxies).", | ||||
| 28 | + rightsNotes: "Public market data displayed with attribution to Bybit.", | ||||
| 29 | + termsUrl: "https://www.bybit.com/en/terms-service/", | ||||
| 30 | + sourceFamily: "bybit", | ||||
| 31 | + enabled: true, | ||||
| 32 | + }, | ||||
| 33 | + seeds: SYMBOLS.map((s) => { | ||||
| 34 | + const [b, q] = splitVenueSymbol(s, null)!; | ||||
| 35 | + return { symbol: s, hint: planPair(b, q, "bybit").hint }; | ||||
| 36 | + }), | ||||
| 37 | + defaultSymbols: SYMBOLS, | ||||
| 38 | + async start(ctx) { | ||||
| 39 | + const ws = ctx.openWebSocket(WS_URL, { | ||||
| 40 | + label: "bybit", | ||||
| 41 | + staleAfterMs: 60_000, | ||||
| 42 | + heartbeat: { intervalMs: 20_000, message: JSON.stringify({ op: "ping" }) }, | ||||
| 43 | + onOpen: (sock) => { | ||||
| 44 | + // Bybit accepts at most 10 topics per subscribe request. | ||||
| 45 | + const topics = ctx.watchedSymbols().map((s) => `tickers.${s}`); | ||||
| 46 | + for (let i = 0; i < topics.length; i += 10) sock.send({ op: "subscribe", args: topics.slice(i, i + 10) }); | ||||
| 47 | + }, | ||||
| 48 | + onMessage: (data) => { | ||||
| 49 | + let msg: any; | ||||
| 50 | + try { | ||||
| 51 | + msg = JSON.parse(data); | ||||
| 52 | + } catch { | ||||
| 53 | + return; | ||||
| 54 | + } | ||||
| 55 | + if (typeof msg?.topic === "string" && msg.topic.startsWith("tickers.") && msg.data) ctx.emit(raw("bybit-ws", "bybit", "ticker", msg)); | ||||
| 56 | + else if (msg?.success === false) ctx.reportError(new Error(String(msg.ret_msg ?? "bybit error"))); | ||||
| 57 | + }, | ||||
| 58 | + }); | ||||
| 59 | + ws.connect(); | ||||
| 60 | + }, | ||||
| 61 | + normalize(r): NormalizedBatch { | ||||
| 62 | + const m = r.payload as { ts?: number; data?: Record<string, unknown>; cs?: number }; | ||||
| 63 | + const d = m.data; | ||||
| 64 | + if (r.kind !== "ticker" || !d || typeof d.symbol !== "string") return { observations: [] }; | ||||
| 65 | + const split = splitVenueSymbol(d.symbol, null); | ||||
| 66 | + if (!split) return { observations: [] }; | ||||
| 67 | + const [base, quote] = split; | ||||
| 68 | + return { | ||||
| 69 | + observations: pairObservations(d.symbol, base, quote, "bybit", { last: d.lastPrice, high: d.highPrice24h, low: d.lowPrice24h, volume: d.volume24h, open: d.prevPrice24h }, { | ||||
| 70 | + sourceTimestamp: parseTimestamp(m.ts), | ||||
| 71 | + timestampTrust: "EXCHANGE", | ||||
| 72 | + sequence: typeof m.cs === "number" ? m.cs : null, | ||||
| 73 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 74 | + realtimeStatus: "REALTIME", | ||||
| 75 | + }), | ||||
| 76 | + }; | ||||
| 77 | + }, | ||||
| 78 | + fixturesDir: "fixtures", | ||||
| 79 | +}); | ||||
modified
connectors/src/cboe-delayed-quotes/index.ts
+1 −1
@@ -79,7 +79,7 @@ export const cboeDelayedQuotes = defineConnector({ | |||||
| 79 | const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN; | 79 | const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN; | ||
| 80 | if (!Number.isFinite(n)) return; | 80 | if (!Number.isFinite(n)) return; | ||
| 81 | if ((field === "BID" || field === "ASK" || field === "LAST_PRICE" || field === "OPEN" || field === "HIGH" || field === "LOW" || field === "PREVIOUS_CLOSE") && n <= 0) return; | 81 | if ((field === "BID" || field === "ASK" || field === "LAST_PRICE" || field === "OPEN" || field === "HIGH" || field === "LOW" || field === "PREVIOUS_CLOSE") && n <= 0) return; | ||
| 82 | − observations.push({ symbol, instrumentHint: hint, field, value: n, currency: "USD", sourceTimestamp: ts, timestampTrust: trust, sequence: seq, rightsStatus: "DELAYED", realtimeStatus: "DELAYED", meta: { security_type: d.security_type } }); | 82 | + observations.push({ symbol, instrumentHint: hint, field, value: n, currency: "USD", observationType: index ? "INDEX_VALUE" : field === "BID" || field === "ASK" ? "QUOTE" : "TRADE", sourceTimestamp: ts, timestampTrust: trust, sequence: seq, rightsStatus: "DELAYED", realtimeStatus: "DELAYED", meta: { security_type: d.security_type } }); | ||
| 83 | }; | 83 | }; | ||
| 84 | push("LAST_PRICE", d.current_price, tradeTs, tradeTs ? "EXCHANGE" : "SOURCE"); | 84 | push("LAST_PRICE", d.current_price, tradeTs, tradeTs ? "EXCHANGE" : "SOURCE"); | ||
| 85 | push("OPEN", d.open, tradeTs, "EXCHANGE"); | 85 | push("OPEN", d.open, tradeTs, "EXCHANGE"); | ||
modified
connectors/src/coinbase-ws/index.ts
+9 −5
@@ -1,9 +1,13 @@ | |||||
| 1 | import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | 1 | import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | ||
| 2 | import { parseTimestamp } from "@market-atlas/market-model"; | 2 | import { parseTimestamp } from "@market-atlas/market-model"; | ||
| 3 | −import { MAJOR_BASES, cryptoSeeds, tickerObservations } from "../_shared/crypto.js"; | 3 | +import { MAJOR_BASES, cryptoSeeds } from "../_shared/crypto.js"; | ||
| 4 | +import { pairObservations } from "../_shared/pairs.js"; | ||||
| 4 | 5 | ||||
| 5 | const WS_URL = "wss://ws-feed.exchange.coinbase.com"; | 6 | const WS_URL = "wss://ws-feed.exchange.coinbase.com"; | ||
| 6 | −const PAIRS: Array<[string, string]> = [...MAJOR_BASES.map((b) => [b, "USD"] as [string, string]), ["BTC", "USDT"], ["ETH", "USDT"], ["BTC", "EUR"], ["ETH", "EUR"]]; | 7 | +const CRYPTO_PAIRS: Array<[string, string]> = [...MAJOR_BASES.map((b) => [b, "USD"] as [string, string]), ["BTC", "USDT"], ["ETH", "USDT"], ["BTC", "EUR"], ["ETH", "EUR"]]; | ||
| 8 | +/** Stablecoin/fiat markets → live FX proxies (USDC ≈ USD). */ | ||||
| 9 | +const FX_PROXY_PAIRS: Array<[string, string]> = [["USDC", "EUR"], ["USDC", "GBP"], ["USDT", "EUR"], ["USDT", "GBP"]]; | ||||
| 10 | +const PAIRS = [...CRYPTO_PAIRS, ...FX_PROXY_PAIRS]; | ||||
| 7 | const SYMBOLS = PAIRS.map(([b, q]) => `${b}-${q}`); | 11 | const SYMBOLS = PAIRS.map(([b, q]) => `${b}-${q}`); | ||
| 8 | 12 | ||||
| 9 | /** | 13 | /** | ||
@@ -24,7 +28,7 @@ export const coinbaseWs = defineConnector({ | |||||
| 24 | expectedLatencyMs: 300, | 28 | expectedLatencyMs: 300, | ||
| 25 | supportsStreaming: true, | 29 | supportsStreaming: true, | ||
| 26 | supportsHistorical: false, | 30 | supportsHistorical: false, | ||
| 27 | − assetClasses: ["CRYPTO"], | 31 | + assetClasses: ["CRYPTO", "FOREX"], | ||
| 28 | exchanges: ["coinbase"], | 32 | exchanges: ["coinbase"], | ||
| 29 | homepage: "https://docs.cdp.coinbase.com/exchange/docs/websocket-overview", | 33 | homepage: "https://docs.cdp.coinbase.com/exchange/docs/websocket-overview", | ||
| 30 | description: "Public, unauthenticated WebSocket market-data feed of Coinbase Exchange (ticker channel). One connection carries every subscribed product.", | 34 | description: "Public, unauthenticated WebSocket market-data feed of Coinbase Exchange (ticker channel). One connection carries every subscribed product.", | ||
@@ -33,7 +37,7 @@ export const coinbaseWs = defineConnector({ | |||||
| 33 | sourceFamily: "coinbase", | 37 | sourceFamily: "coinbase", | ||
| 34 | enabled: true, | 38 | enabled: true, | ||
| 35 | }, | 39 | }, | ||
| 36 | − seeds: cryptoSeeds(PAIRS, "coinbase", (b, q) => `${b}-${q}`), | 40 | + seeds: cryptoSeeds(CRYPTO_PAIRS, "coinbase", (b, q) => `${b}-${q}`), | ||
| 37 | defaultSymbols: SYMBOLS, | 41 | defaultSymbols: SYMBOLS, | ||
| 38 | async start(ctx) { | 42 | async start(ctx) { | ||
| 39 | const ws = ctx.openWebSocket(WS_URL, { | 43 | const ws = ctx.openWebSocket(WS_URL, { | ||
@@ -62,7 +66,7 @@ export const coinbaseWs = defineConnector({ | |||||
| 62 | const [base, quote] = m.product_id.split("-"); | 66 | const [base, quote] = m.product_id.split("-"); | ||
| 63 | if (!base || !quote) return { observations: [] }; | 67 | if (!base || !quote) return { observations: [] }; | ||
| 64 | return { | 68 | return { | ||
| 65 | − observations: tickerObservations(m.product_id, base, quote, "coinbase", { last: m.price, open: m.open_24h, high: m.high_24h, low: m.low_24h, bid: m.best_bid, ask: m.best_ask, bidSize: m.best_bid_size, askSize: m.best_ask_size, volume: m.volume_24h }, { | 69 | + observations: pairObservations(m.product_id, base, quote, "coinbase", { last: m.price, open: m.open_24h, high: m.high_24h, low: m.low_24h, bid: m.best_bid, ask: m.best_ask, bidSize: m.best_bid_size, askSize: m.best_ask_size, volume: m.volume_24h }, { | ||
| 66 | sourceTimestamp: parseTimestamp(m.time), | 70 | sourceTimestamp: parseTimestamp(m.time), | ||
| 67 | timestampTrust: "EXCHANGE", | 71 | timestampTrust: "EXCHANGE", | ||
| 68 | sequence: typeof m.sequence === "number" ? m.sequence : null, | 72 | sequence: typeof m.sequence === "number" ? m.sequence : null, | ||
modified
connectors/src/connectors.test.ts
+82 −0
@@ -245,3 +245,85 @@ describe("hfmarketdata-daily", () => { | |||||
| 245 | expect(hfmarketdata.normalize({ connectorId: "hfmarketdata-daily", sourceId: "hfmarketdata", kind: "bars", payload: { data: [] }, receivedAt: 0, meta: { symbol: "NOPE" } }).observations).toHaveLength(0); | 245 | expect(hfmarketdata.normalize({ connectorId: "hfmarketdata-daily", sourceId: "hfmarketdata", kind: "bars", payload: { data: [] }, receivedAt: 0, meta: { symbol: "NOPE" } }).observations).toHaveLength(0); | ||
| 246 | }); | 246 | }); | ||
| 247 | }); | 247 | }); | ||
| 248 | + | ||||
| 249 | +describe("source mesh v2 — venues, live FX and proxies", async () => { | ||||
| 250 | + const { bitstampWs } = await import("./bitstamp-ws/index.js"); | ||||
| 251 | + const { geminiWs } = await import("./gemini-ws/index.js"); | ||||
| 252 | + const { bitfinexWs } = await import("./bitfinex-ws/index.js"); | ||||
| 253 | + const { bybitWs } = await import("./bybit-ws/index.js"); | ||||
| 254 | + const { gateWs } = await import("./gate-ws/index.js"); | ||||
| 255 | + const { cryptocomWs } = await import("./cryptocom-ws/index.js"); | ||||
| 256 | + const { kucoinWs } = await import("./kucoin-ws/index.js"); | ||||
| 257 | + const { nasdaqQuoteApi, parseNasdaqTime } = await import("./nasdaq-quote-api/index.js"); | ||||
| 258 | + const { planPair } = await import("./_shared/pairs.js"); | ||||
| 259 | + | ||||
| 260 | + it("plans venue pairs: crypto, real fiat markets, stablecoin proxies (with inversion)", () => { | ||||
| 261 | + expect(planPair("BTC", "USD", "x").hint.assetClass).toBe("CRYPTO"); | ||||
| 262 | + const fx = planPair("EUR", "USD", "kraken"); | ||||
| 263 | + expect(fx.hint.assetClass).toBe("FOREX"); | ||||
| 264 | + expect(fx.type).toBe("TRADE"); | ||||
| 265 | + const inv = planPair("USDT", "EUR", "okx"); | ||||
| 266 | + expect(inv.type).toBe("STABLECOIN_PROXY"); | ||||
| 267 | + expect(inv.hint.base).toBe("EUR"); | ||||
| 268 | + expect(inv.invert).toBe(true); | ||||
| 269 | + const brl = planPair("USDT", "BRL", "binance"); | ||||
| 270 | + expect(brl.hint.base).toBe("USD"); | ||||
| 271 | + expect(brl.hint.quote).toBe("BRL"); | ||||
| 272 | + expect(brl.invert).toBe(false); | ||||
| 273 | + }); | ||||
| 274 | + it("kraken fiat ticker → FOREX TRADE observation on the conventional pair", () => { | ||||
| 275 | + const b = normalizeFixture(krakenWs, "ticker", { channel: "ticker", type: "update", data: [{ symbol: "EUR/USD", bid: 1.1724, ask: 1.1726, last: 1.1725, volume: 120000.5, high: 1.175, low: 1.17 }] }); | ||||
| 276 | + const last = b.observations.find((o) => o.field === "LAST_PRICE")!; | ||||
| 277 | + expect(last.instrumentHint?.assetClass).toBe("FOREX"); | ||||
| 278 | + expect(last.observationType).toBe("TRADE"); | ||||
| 279 | + expect(last.value).toBe(1.1725); | ||||
| 280 | + }); | ||||
| 281 | + it("okx USDT-EUR ticker → inverted EURUSD stablecoin proxy", () => { | ||||
| 282 | + const b = normalizeFixture(okxWs, "tickers", { arg: { channel: "tickers", instId: "USDT-EUR" }, data: [{ instId: "USDT-EUR", last: "0.8530", bidPx: "0.8529", askPx: "0.8531", ts: "1789199660123" }] }); | ||||
| 283 | + const last = b.observations.find((o) => o.field === "LAST_PRICE")!; | ||||
| 284 | + expect(last.instrumentHint?.base).toBe("EUR"); | ||||
| 285 | + expect(last.observationType).toBe("STABLECOIN_PROXY"); | ||||
| 286 | + expect(last.value).toBeCloseTo(1 / 0.853, 6); | ||||
| 287 | + const bid = b.observations.find((o) => o.field === "BID")!; | ||||
| 288 | + expect(bid.value).toBeCloseTo(1 / 0.8531, 6); // venue ask becomes our bid | ||||
| 289 | + expect(b.observations.some((o) => o.field === "VOLUME")).toBe(false); | ||||
| 290 | + }); | ||||
| 291 | + it("bitstamp trade + book (EUR/USD fiat market)", () => { | ||||
| 292 | + const t = normalizeFixture(bitstampWs, "trade", fx("bitstamp-ws", "trade.json")); | ||||
| 293 | + expect(t.observations[0]).toMatchObject({ field: "LAST_PRICE", value: 77203.59, observationType: "TRADE" }); | ||||
| 294 | + expect(t.observations[0]!.sourceTimestamp).toBe(1789273894413); | ||||
| 295 | + const bk = normalizeFixture(bitstampWs, "book", fx("bitstamp-ws", "book.json")); | ||||
| 296 | + expect(bk.observations.find((o) => o.field === "BID")?.value).toBe(1.17252); | ||||
| 297 | + expect(bk.observations[0]!.instrumentHint?.assetClass).toBe("FOREX"); | ||||
| 298 | + }); | ||||
| 299 | + it("gemini, bitfinex, bybit, gate, crypto.com, kucoin tickers", () => { | ||||
| 300 | + expect(normalizeFixture(geminiWs, "trade", fx("gemini-ws", "trade.json")).observations[0]).toMatchObject({ field: "LAST_PRICE", value: 77212.78, sourceTimestamp: 1789274085234 }); | ||||
| 301 | + const bf = normalizeFixture(bitfinexWs, "ticker", fx("bitfinex-ws", "ticker.json")); | ||||
| 302 | + expect(bf.observations.find((o) => o.field === "LAST_PRICE")?.value).toBe(77118); | ||||
| 303 | + expect(bf.observations.find((o) => o.field === "BID")?.value).toBe(77123); | ||||
| 304 | + expect(normalizeFixture(bybitWs, "ticker", fx("bybit-ws", "ticker.json")).observations.find((o) => o.field === "LAST_PRICE")?.value).toBe(77229.4); | ||||
| 305 | + expect(normalizeFixture(gateWs, "ticker", fx("gate-ws", "ticker.json")).observations.find((o) => o.field === "ASK")?.value).toBe(77227.8); | ||||
| 306 | + expect(normalizeFixture(cryptocomWs, "ticker", fx("cryptocom-ws", "ticker.json")).observations.find((o) => o.field === "LAST_PRICE")?.value).toBe(77219.2); | ||||
| 307 | + const ku = normalizeFixture(kucoinWs, "ticker", fx("kucoin-ws", "ticker.json")); | ||||
| 308 | + expect(ku.observations.find((o) => o.field === "LAST_PRICE")?.value).toBe(77225.1); | ||||
| 309 | + expect(ku.observations[0]!.sourceTimestamp).toBe(1789274087087); | ||||
| 310 | + }); | ||||
| 311 | + it("nasdaq.com quote is a restricted validator with parsed Eastern timestamps", () => { | ||||
| 312 | + const b = normalizeFixture(nasdaqQuoteApi, "info", fx("nasdaq-quote-api", "info.json")); | ||||
| 313 | + const last = b.observations.find((o) => o.field === "LAST_PRICE")!; | ||||
| 314 | + expect(last.value).toBe(332.27); | ||||
| 315 | + expect(last.rightsStatus).toBe("PUBLIC_RESTRICTED_REDISTRIBUTION"); | ||||
| 316 | + expect(new Date(last.sourceTimestamp!).toISOString()).toBe("2026-09-10T20:00:00.000Z"); | ||||
| 317 | + expect(b.observations.find((o) => o.field === "VOLUME")?.value).toBe(50716997); | ||||
| 318 | + expect(parseNasdaqTime("Sep 12, 2026 9:45 AM ET")).toBe(Date.parse("2026-09-12T13:45:00Z")); | ||||
| 319 | + expect(parseNasdaqTime("garbage")).toBeNull(); | ||||
| 320 | + }); | ||||
| 321 | + it("every streaming connector still opens exactly one socket", async () => { | ||||
| 322 | + for (const def of [bitstampWs, geminiWs, bitfinexWs, bybitWs, gateWs, cryptocomWs]) { | ||||
| 323 | + const ctx = makeTestContext(def); | ||||
| 324 | + await def.start!(ctx); | ||||
| 325 | + expect(ctx.sockets).toHaveLength(1); | ||||
| 326 | + ctx.sockets[0]!.close(); | ||||
| 327 | + } | ||||
| 328 | + }); | ||||
| 329 | +}); | ||||
added
connectors/src/cryptocom-ws/README.md
+7 −0
@@ -0,0 +1,7 @@ | |||||
| 1 | +# cryptocom-ws | ||||
| 2 | + | ||||
| 3 | +Crypto.com Exchange public market stream (`wss://stream.crypto.com/exchange/v1/market`), channels `ticker.<INSTRUMENT>`. | ||||
| 4 | + | ||||
| 5 | +- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (tick `t`) | ||||
| 6 | +- **Fields**: LAST_PRICE (`a`), BID/ASK (`b`/`k` + sizes), HIGH/LOW (24 h), VOLUME (24 h). | ||||
| 7 | +- **Protocol quirks**: subscribe only ~1 s after the connection opens (documented); the server sends `public/heartbeat` frames that must be answered with `public/respond-heartbeat` or the connection is dropped. | ||||
added
connectors/src/cryptocom-ws/fixtures/ticker.json
+1 −0
@@ -0,0 +1 @@ | |||||
| 1 | +{"instrument_name":"BTC_USD","subscription":"ticker.BTC_USD","channel":"ticker","data":[{"h":"77506.01","l":"77056.96","a":"77219.20","c":"-0.0007","b":"77219.19","bs":"0.47190","k":"77219.20","ks":"0.29415","i":"BTC_USD","v":"1023.4412","vv":"79012345.12","oi":"0","t":1789273912345}]} | ||||
added
connectors/src/cryptocom-ws/index.ts
+77 −0
@@ -0,0 +1,77 @@ | |||||
| 1 | +import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | ||||
| 2 | +import { parseTimestamp } from "@market-atlas/market-model"; | ||||
| 3 | +import { MAJOR_BASES } from "../_shared/crypto.js"; | ||||
| 4 | +import { pairObservations, planPair } from "../_shared/pairs.js"; | ||||
| 5 | + | ||||
| 6 | +const WS_URL = "wss://stream.crypto.com/exchange/v1/market"; | ||||
| 7 | +const SYMBOLS = [...MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => `${b}_USD`), "BTC_USDT", "ETH_USDT"]; | ||||
| 8 | + | ||||
| 9 | +/** Crypto.com Exchange public market stream, `ticker.<instrument>`. The server sends `public/heartbeat` that must be answered. */ | ||||
| 10 | +export const cryptocomWs = defineConnector({ | ||||
| 11 | + metadata: { | ||||
| 12 | + id: "cryptocom-ws", | ||||
| 13 | + name: "Crypto.com Exchange — ticker channel", | ||||
| 14 | + version: "1.0.0", | ||||
| 15 | + sourceId: "cryptocom", | ||||
| 16 | + organization: "Crypto.com", | ||||
| 17 | + sourceType: "WEBSOCKET", | ||||
| 18 | + jurisdiction: "SG", | ||||
| 19 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 20 | + realtimeStatus: "REALTIME", | ||||
| 21 | + expectedLatencyMs: 500, | ||||
| 22 | + supportsStreaming: true, | ||||
| 23 | + supportsHistorical: false, | ||||
| 24 | + assetClasses: ["CRYPTO"], | ||||
| 25 | + exchanges: ["cryptocom"], | ||||
| 26 | + homepage: "https://exchange-docs.crypto.com/exchange/v1/rest-ws/index.html#ticker-instrument_name", | ||||
| 27 | + description: "Public Crypto.com Exchange ticker stream (last, best bid/ask with sizes, 24h high/low/volume) for USD-quoted majors and BTC/ETH in USDT.", | ||||
| 28 | + rightsNotes: "Public market data displayed with attribution to Crypto.com Exchange.", | ||||
| 29 | + termsUrl: "https://crypto.com/exchange/document/terms-of-service", | ||||
| 30 | + sourceFamily: "cryptocom", | ||||
| 31 | + enabled: true, | ||||
| 32 | + }, | ||||
| 33 | + seeds: SYMBOLS.map((s) => { | ||||
| 34 | + const [b, q] = s.split("_") as [string, string]; | ||||
| 35 | + return { symbol: s, hint: planPair(b, q, "cryptocom").hint }; | ||||
| 36 | + }), | ||||
| 37 | + defaultSymbols: SYMBOLS, | ||||
| 38 | + async start(ctx) { | ||||
| 39 | + const ws = ctx.openWebSocket(WS_URL, { | ||||
| 40 | + label: "cryptocom", | ||||
| 41 | + staleAfterMs: 60_000, | ||||
| 42 | + heartbeat: null, // server-initiated heartbeats answered below | ||||
| 43 | + onOpen: (sock) => setTimeout(() => sock.send({ id: 1, method: "subscribe", params: { channels: ctx.watchedSymbols().map((s) => `ticker.${s}`) } }), 1000), // docs: wait 1 s after connect | ||||
| 44 | + onMessage: (data, sock) => { | ||||
| 45 | + let msg: any; | ||||
| 46 | + try { | ||||
| 47 | + msg = JSON.parse(data); | ||||
| 48 | + } catch { | ||||
| 49 | + return; | ||||
| 50 | + } | ||||
| 51 | + if (msg?.method === "public/heartbeat") return void sock.send({ id: msg.id, method: "public/respond-heartbeat" }); | ||||
| 52 | + if (msg?.method === "subscribe" && msg.result?.channel === "ticker" && Array.isArray(msg.result.data)) ctx.emit(raw("cryptocom-ws", "cryptocom", "ticker", msg.result)); | ||||
| 53 | + else if (msg?.code && msg.code !== 0) ctx.reportError(new Error(String(msg.message ?? `cryptocom code ${msg.code}`))); | ||||
| 54 | + }, | ||||
| 55 | + }); | ||||
| 56 | + ws.connect(); | ||||
| 57 | + }, | ||||
| 58 | + normalize(r): NormalizedBatch { | ||||
| 59 | + const m = r.payload as { instrument_name?: string; data?: Array<Record<string, unknown>> }; | ||||
| 60 | + if (r.kind !== "ticker" || typeof m.instrument_name !== "string" || !Array.isArray(m.data)) return { observations: [] }; | ||||
| 61 | + const [base, quote] = m.instrument_name.split("_"); | ||||
| 62 | + if (!base || !quote) return { observations: [] }; | ||||
| 63 | + const observations = []; | ||||
| 64 | + for (const t of m.data) { | ||||
| 65 | + observations.push( | ||||
| 66 | + ...pairObservations(m.instrument_name, base, quote, "cryptocom", { last: t.a, bid: t.b, bidSize: t.bs, ask: t.k, askSize: t.ks, high: t.h, low: t.l, volume: t.v }, { | ||||
| 67 | + sourceTimestamp: parseTimestamp(t.t), | ||||
| 68 | + timestampTrust: "EXCHANGE", | ||||
| 69 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 70 | + realtimeStatus: "REALTIME", | ||||
| 71 | + }), | ||||
| 72 | + ); | ||||
| 73 | + } | ||||
| 74 | + return { observations }; | ||||
| 75 | + }, | ||||
| 76 | + fixturesDir: "fixtures", | ||||
| 77 | +}); | ||||
modified
connectors/src/ecb-frankfurter/index.ts
+1 −1
@@ -85,7 +85,7 @@ export const ecbFrankfurter = defineConnector({ | |||||
| 85 | const [b, q] = conventionalPair(p.base!, ccy); | 85 | const [b, q] = conventionalPair(p.base!, ccy); | ||
| 86 | if (p.base === "USD" && (b === "EUR" || q === "EUR")) continue; | 86 | if (p.base === "USD" && (b === "EUR" || q === "EUR")) continue; | ||
| 87 | const value = b === p.base ? rate : 1 / rate; | 87 | const value = b === p.base ? rate : 1 / rate; | ||
| 88 | − observations.push({ symbol: `${b}${q}`, instrumentHint: fxHint(b, q), field, value: Number(value.toPrecision(8)), currency: q, sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { fixing_date: date, reference_date: refDate, base: p.base } }); | 88 | + observations.push({ symbol: `${b}${q}`, instrumentHint: fxHint(b, q), field, value: Number(value.toPrecision(8)), currency: q, observationType: field === "PREVIOUS_CLOSE" ? "OFFICIAL_FIX" : "OFFICIAL_FIX", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { fixing_date: date, reference_date: refDate, base: p.base } }); | ||
| 89 | } | 89 | } | ||
| 90 | }; | 90 | }; | ||
| 91 | emit(latest, "LAST_PRICE", latest); | 91 | emit(latest, "LAST_PRICE", latest); | ||
added
connectors/src/gate-ws/README.md
+7 −0
@@ -0,0 +1,7 @@ | |||||
| 1 | +# gate-ws | ||||
| 2 | + | ||||
| 3 | +Gate.io spot WebSocket v4 (`wss://api.gateio.ws/ws/v4/`), channel `spot.tickers`. | ||||
| 4 | + | ||||
| 5 | +- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (frame `time_ms`) | ||||
| 6 | +- **Fields**: LAST_PRICE, BID (`highest_bid`), ASK (`lowest_ask`), HIGH/LOW (24 h), VOLUME (24 h base). | ||||
| 7 | +- **Heartbeat**: `{"channel":"spot.ping"}` every 20 s. Updates arrive roughly every 5 s per pair. | ||||
added
connectors/src/gate-ws/fixtures/ticker.json
+1 −0
@@ -0,0 +1 @@ | |||||
| 1 | +{"time":1789273915,"time_ms":1789273915820,"channel":"spot.tickers","event":"update","result":{"currency_pair":"BTC_USDT","last":"77227.8","lowest_ask":"77227.8","highest_bid":"77227.7","change_percentage":"-0.06","base_volume":"1304.867845","quote_volume":"100800000.1","high_24h":"77520.0","low_24h":"77000.1"}} | ||||
added
connectors/src/gate-ws/index.ts
+73 −0
@@ -0,0 +1,73 @@ | |||||
| 1 | +import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | ||||
| 2 | +import { parseTimestamp } from "@market-atlas/market-model"; | ||||
| 3 | +import { MAJOR_BASES } from "../_shared/crypto.js"; | ||||
| 4 | +import { pairObservations, planPair } from "../_shared/pairs.js"; | ||||
| 5 | + | ||||
| 6 | +const WS_URL = "wss://api.gateio.ws/ws/v4/"; | ||||
| 7 | +const SYMBOLS = MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => `${b}_USDT`); | ||||
| 8 | + | ||||
| 9 | +/** Gate.io spot WebSocket v4, `spot.tickers` channel. Ping via `spot.ping`. */ | ||||
| 10 | +export const gateWs = defineConnector({ | ||||
| 11 | + metadata: { | ||||
| 12 | + id: "gate-ws", | ||||
| 13 | + name: "Gate — spot tickers (WS v4)", | ||||
| 14 | + version: "1.0.0", | ||||
| 15 | + sourceId: "gate", | ||||
| 16 | + organization: "Gate Technology Inc.", | ||||
| 17 | + sourceType: "WEBSOCKET", | ||||
| 18 | + jurisdiction: null, | ||||
| 19 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 20 | + realtimeStatus: "REALTIME", | ||||
| 21 | + expectedLatencyMs: 1000, | ||||
| 22 | + supportsStreaming: true, | ||||
| 23 | + supportsHistorical: false, | ||||
| 24 | + assetClasses: ["CRYPTO"], | ||||
| 25 | + exchanges: ["gate"], | ||||
| 26 | + homepage: "https://www.gate.io/docs/developers/apiv4/ws/en/#tickers-channel", | ||||
| 27 | + description: "Public Gate.io spot ticker channel (last, best bid/ask, 24h high/low/volume) for USDT majors.", | ||||
| 28 | + rightsNotes: "Public market data displayed with attribution to Gate.", | ||||
| 29 | + termsUrl: "https://www.gate.io/user-agreement", | ||||
| 30 | + sourceFamily: "gate", | ||||
| 31 | + enabled: true, | ||||
| 32 | + }, | ||||
| 33 | + seeds: SYMBOLS.map((s) => { | ||||
| 34 | + const [b, q] = s.split("_") as [string, string]; | ||||
| 35 | + return { symbol: s, hint: planPair(b, q, "gate").hint }; | ||||
| 36 | + }), | ||||
| 37 | + defaultSymbols: SYMBOLS, | ||||
| 38 | + async start(ctx) { | ||||
| 39 | + const ws = ctx.openWebSocket(WS_URL, { | ||||
| 40 | + label: "gate", | ||||
| 41 | + staleAfterMs: 60_000, | ||||
| 42 | + heartbeat: { intervalMs: 20_000, message: JSON.stringify({ time: Math.floor(Date.now() / 1000), channel: "spot.ping" }) }, | ||||
| 43 | + onOpen: (sock) => sock.send({ time: Math.floor(Date.now() / 1000), channel: "spot.tickers", event: "subscribe", payload: ctx.watchedSymbols() }), | ||||
| 44 | + onMessage: (data) => { | ||||
| 45 | + let msg: any; | ||||
| 46 | + try { | ||||
| 47 | + msg = JSON.parse(data); | ||||
| 48 | + } catch { | ||||
| 49 | + return; | ||||
| 50 | + } | ||||
| 51 | + if (msg?.channel === "spot.tickers" && msg.event === "update" && msg.result) ctx.emit(raw("gate-ws", "gate", "ticker", msg)); | ||||
| 52 | + else if (msg?.error) ctx.reportError(new Error(String(msg.error.message ?? "gate error"))); | ||||
| 53 | + }, | ||||
| 54 | + }); | ||||
| 55 | + ws.connect(); | ||||
| 56 | + }, | ||||
| 57 | + normalize(r): NormalizedBatch { | ||||
| 58 | + const m = r.payload as { time_ms?: number; result?: Record<string, unknown> }; | ||||
| 59 | + const d = m.result; | ||||
| 60 | + if (r.kind !== "ticker" || !d || typeof d.currency_pair !== "string") return { observations: [] }; | ||||
| 61 | + const [base, quote] = d.currency_pair.split("_"); | ||||
| 62 | + if (!base || !quote) return { observations: [] }; | ||||
| 63 | + return { | ||||
| 64 | + observations: pairObservations(d.currency_pair, base, quote, "gate", { last: d.last, bid: d.highest_bid, ask: d.lowest_ask, high: d.high_24h, low: d.low_24h, volume: d.base_volume }, { | ||||
| 65 | + sourceTimestamp: parseTimestamp(m.time_ms), | ||||
| 66 | + timestampTrust: "EXCHANGE", | ||||
| 67 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 68 | + realtimeStatus: "REALTIME", | ||||
| 69 | + }), | ||||
| 70 | + }; | ||||
| 71 | + }, | ||||
| 72 | + fixturesDir: "fixtures", | ||||
| 73 | +}); | ||||
added
connectors/src/gemini-ws/README.md
+7 −0
@@ -0,0 +1,7 @@ | |||||
| 1 | +# gemini-ws | ||||
| 2 | + | ||||
| 3 | +Gemini market data v2 (`wss://api.gemini.com/v2/marketdata`), `l2` subscription. | ||||
| 4 | + | ||||
| 5 | +- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (exchange `timestamp` ms) | ||||
| 6 | +- **Output**: LAST_PRICE from `trade` events (the initial `l2_updates` snapshot carries recent trades → the newest is emitted). Order-book deltas are not aggregated (no BID/ASK from this connector). | ||||
| 7 | +- **Cadence**: trade-driven — quiet pairs update rarely; the stale threshold is therefore 3 minutes. | ||||
added
connectors/src/gemini-ws/fixtures/trade.json
+1 −0
@@ -0,0 +1 @@ | |||||
| 1 | +{"type":"trade","symbol":"BTCUSD","event_id":1789274085123456,"timestamp":1789274085234,"price":"77212.78","quantity":"0.0125","side":"buy"} | ||||
added
connectors/src/gemini-ws/index.ts
+73 −0
@@ -0,0 +1,73 @@ | |||||
| 1 | +import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | ||||
| 2 | +import { parseTimestamp } from "@market-atlas/market-model"; | ||||
| 3 | +import { pairObservations, planPair, splitVenueSymbol } from "../_shared/pairs.js"; | ||||
| 4 | + | ||||
| 5 | +const WS_URL = "wss://api.gemini.com/v2/marketdata"; | ||||
| 6 | +const SYMBOLS = ["BTCUSD", "ETHUSD", "SOLUSD", "XRPUSD", "LTCUSD", "LINKUSD", "DOGEUSD", "AVAXUSD", "DOTUSD", "BTCGUSD"]; | ||||
| 7 | + | ||||
| 8 | +/** Gemini market data v2, `l2` subscription: initial snapshot with recent trades, then `trade` events. */ | ||||
| 9 | +export const geminiWs = defineConnector({ | ||||
| 10 | + metadata: { | ||||
| 11 | + id: "gemini-ws", | ||||
| 12 | + name: "Gemini — market data v2 (trades)", | ||||
| 13 | + version: "1.0.0", | ||||
| 14 | + sourceId: "gemini", | ||||
| 15 | + organization: "Gemini Trust Company, LLC", | ||||
| 16 | + sourceType: "WEBSOCKET", | ||||
| 17 | + jurisdiction: "US", | ||||
| 18 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 19 | + realtimeStatus: "REALTIME", | ||||
| 20 | + expectedLatencyMs: 400, | ||||
| 21 | + supportsStreaming: true, | ||||
| 22 | + supportsHistorical: false, | ||||
| 23 | + assetClasses: ["CRYPTO"], | ||||
| 24 | + exchanges: ["gemini"], | ||||
| 25 | + homepage: "https://docs.gemini.com/websocket-api/#market-data-version-2", | ||||
| 26 | + description: "Public Gemini market-data v2 feed (l2 subscription): last trades for the major USD pairs with exchange timestamps.", | ||||
| 27 | + rightsNotes: "Public market data displayed with attribution to Gemini.", | ||||
| 28 | + termsUrl: "https://www.gemini.com/legal/api-agreement", | ||||
| 29 | + sourceFamily: "gemini", | ||||
| 30 | + enabled: true, | ||||
| 31 | + }, | ||||
| 32 | + seeds: SYMBOLS.map((s) => { | ||||
| 33 | + const [b, q] = splitVenueSymbol(s, null)!; | ||||
| 34 | + return { symbol: s, hint: planPair(b, q, "gemini").hint, aliases: [`${b}-${q}`, `${b}/${q}`] }; | ||||
| 35 | + }), | ||||
| 36 | + defaultSymbols: SYMBOLS, | ||||
| 37 | + async start(ctx) { | ||||
| 38 | + const ws = ctx.openWebSocket(WS_URL, { | ||||
| 39 | + label: "gemini", | ||||
| 40 | + staleAfterMs: 180_000, | ||||
| 41 | + heartbeat: { intervalMs: 25_000 }, | ||||
| 42 | + onOpen: (sock) => sock.send({ type: "subscribe", subscriptions: [{ name: "l2", symbols: ctx.watchedSymbols() }] }), | ||||
| 43 | + onMessage: (data) => { | ||||
| 44 | + let msg: any; | ||||
| 45 | + try { | ||||
| 46 | + msg = JSON.parse(data); | ||||
| 47 | + } catch { | ||||
| 48 | + return; | ||||
| 49 | + } | ||||
| 50 | + if (msg?.type === "trade") ctx.emit(raw("gemini-ws", "gemini", "trade", msg)); | ||||
| 51 | + else if (msg?.type === "l2_updates" && Array.isArray(msg.trades) && msg.trades.length) ctx.emit(raw("gemini-ws", "gemini", "trade", { ...msg.trades[msg.trades.length - 1], symbol: msg.symbol, type: "trade" })); | ||||
| 52 | + }, | ||||
| 53 | + }); | ||||
| 54 | + ws.connect(); | ||||
| 55 | + }, | ||||
| 56 | + normalize(r): NormalizedBatch { | ||||
| 57 | + const m = r.payload as Record<string, unknown>; | ||||
| 58 | + if (r.kind !== "trade" || typeof m.symbol !== "string") return { observations: [] }; | ||||
| 59 | + const split = splitVenueSymbol(m.symbol, null); | ||||
| 60 | + if (!split) return { observations: [] }; | ||||
| 61 | + const [base, quote] = split; | ||||
| 62 | + return { | ||||
| 63 | + observations: pairObservations(m.symbol, base, quote, "gemini", { last: m.price }, { | ||||
| 64 | + sourceTimestamp: parseTimestamp(m.timestamp), | ||||
| 65 | + timestampTrust: "EXCHANGE", | ||||
| 66 | + sequence: typeof m.event_id === "number" ? m.event_id : null, | ||||
| 67 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 68 | + realtimeStatus: "REALTIME", | ||||
| 69 | + }), | ||||
| 70 | + }; | ||||
| 71 | + }, | ||||
| 72 | + fixturesDir: "fixtures", | ||||
| 73 | +}); | ||||
modified
connectors/src/hfmarketdata/index.ts
+1 −1
@@ -124,7 +124,7 @@ export const hfmarketdata = defineConnector({ | |||||
| 124 | const observations: NormalizedObservation[] = []; | 124 | const observations: NormalizedObservation[] = []; | ||
| 125 | if (last) { | 125 | if (last) { | ||
| 126 | const ts = zonedTimeToUtc(`${last.date}T${target.sessionCloseLocal.length === 5 ? `${target.sessionCloseLocal}:00` : target.sessionCloseLocal}`, target.timezone); | 126 | const ts = zonedTimeToUtc(`${last.date}T${target.sessionCloseLocal.length === 5 ? `${target.sessionCloseLocal}:00` : target.sessionCloseLocal}`, target.timezone); | ||
| 127 | − const base = { symbol: target.symbol, instrumentHint: target.hint, currency: target.hint.currency ?? "USD", sourceTimestamp: ts, timestampTrust: "SOURCE" as const, rightsStatus: "LICENSED" as const, realtimeStatus: "END_OF_DAY" as const, meta: { session: last.date, contract: last.contract } }; | 127 | + const base = { symbol: target.symbol, instrumentHint: target.hint, currency: target.hint.currency ?? "USD", observationType: "EOD_CLOSE" as const, sourceTimestamp: ts, timestampTrust: "SOURCE" as const, rightsStatus: "LICENSED" as const, realtimeStatus: "END_OF_DAY" as const, meta: { session: last.date, contract: last.contract } }; | ||
| 128 | observations.push({ ...base, field: "LAST_PRICE", value: last.close! }, { ...base, field: "CLOSE", value: last.close! }, { ...base, field: "OPEN", value: last.open! }, { ...base, field: "HIGH", value: last.high! }, { ...base, field: "LOW", value: last.low! }); | 128 | observations.push({ ...base, field: "LAST_PRICE", value: last.close! }, { ...base, field: "CLOSE", value: last.close! }, { ...base, field: "OPEN", value: last.open! }, { ...base, field: "HIGH", value: last.high! }, { ...base, field: "LOW", value: last.low! }); | ||
| 129 | if (last.volume != null) observations.push({ ...base, field: "VOLUME", value: last.volume }); | 129 | if (last.volume != null) observations.push({ ...base, field: "VOLUME", value: last.volume }); | ||
| 130 | if (prev) observations.push({ ...base, field: "PREVIOUS_CLOSE", value: prev.close!, meta: { session: prev.date } }); | 130 | if (prev) observations.push({ ...base, field: "PREVIOUS_CLOSE", value: prev.close!, meta: { session: prev.date } }); | ||
modified
connectors/src/index.ts
+16 −0
@@ -13,6 +13,14 @@ import { nasdaqSymbolDirectory } from "./nasdaq-symbol-directory/index.js"; | |||||
| 13 | import { nasdaqTradeHalts } from "./nasdaq-trade-halts/index.js"; | 13 | import { nasdaqTradeHalts } from "./nasdaq-trade-halts/index.js"; | ||
| 14 | import { nasdaqMarketCalendar } from "./nasdaq-market-calendar/index.js"; | 14 | import { nasdaqMarketCalendar } from "./nasdaq-market-calendar/index.js"; | ||
| 15 | import { hfmarketdata } from "./hfmarketdata/index.js"; | 15 | import { hfmarketdata } from "./hfmarketdata/index.js"; | ||
| 16 | +import { bitstampWs } from "./bitstamp-ws/index.js"; | ||||
| 17 | +import { geminiWs } from "./gemini-ws/index.js"; | ||||
| 18 | +import { bitfinexWs } from "./bitfinex-ws/index.js"; | ||||
| 19 | +import { bybitWs } from "./bybit-ws/index.js"; | ||||
| 20 | +import { gateWs } from "./gate-ws/index.js"; | ||||
| 21 | +import { cryptocomWs } from "./cryptocom-ws/index.js"; | ||||
| 22 | +import { kucoinWs } from "./kucoin-ws/index.js"; | ||||
| 23 | +import { nasdaqQuoteApi } from "./nasdaq-quote-api/index.js"; | ||||
| 16 | 24 | ||||
| 17 | export { SOURCES, sourceById } from "./sources.js"; | 25 | export { SOURCES, sourceById } from "./sources.js"; | ||
| 18 | 26 | ||||
@@ -28,7 +36,15 @@ export const CONNECTORS: ConnectorDefinition[] = [ | |||||
| 28 | krakenWs, | 36 | krakenWs, | ||
| 29 | binanceWs, | 37 | binanceWs, | ||
| 30 | okxWs, | 38 | okxWs, | ||
| 39 | + bitstampWs, | ||||
| 40 | + geminiWs, | ||||
| 41 | + bitfinexWs, | ||||
| 42 | + bybitWs, | ||||
| 43 | + gateWs, | ||||
| 44 | + cryptocomWs, | ||||
| 45 | + kucoinWs, | ||||
| 31 | cboeDelayedQuotes, | 46 | cboeDelayedQuotes, | ||
| 47 | + nasdaqQuoteApi, | ||||
| 32 | ecbFrankfurter, | 48 | ecbFrankfurter, | ||
| 33 | bankOfCanada, | 49 | bankOfCanada, | ||
| 34 | usTreasuryYieldCurve, | 50 | usTreasuryYieldCurve, | ||
modified
connectors/src/kraken-ws/index.ts
+12 −6
@@ -1,9 +1,15 @@ | |||||
| 1 | import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | 1 | import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | ||
| 2 | −import { MAJOR_BASES, cryptoSeeds, tickerObservations } from "../_shared/crypto.js"; | 2 | +import { MAJOR_BASES, cryptoSeeds } from "../_shared/crypto.js"; | ||
| 3 | +import { pairObservations, planPair } from "../_shared/pairs.js"; | ||||
| 4 | +import type { ProposedInstrument } from "@market-atlas/connector-sdk"; | ||||
| 3 | 5 | ||||
| 4 | const WS_URL = "wss://ws.kraken.com/v2"; | 6 | const WS_URL = "wss://ws.kraken.com/v2"; | ||
| 5 | −const PAIRS: Array<[string, string]> = [...MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USD"] as [string, string]), ["BTC", "USDT"], ["ETH", "USDT"], ["BTC", "EUR"], ["ETH", "EUR"]]; | 7 | +const CRYPTO_PAIRS: Array<[string, string]> = [...MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USD"] as [string, string]), ["BTC", "USDT"], ["ETH", "USDT"], ["BTC", "EUR"], ["ETH", "EUR"]]; | ||
| 8 | +/** Kraken runs real fiat/fiat spot markets — a live, keyless FX source. */ | ||||
| 9 | +const FIAT_PAIRS: Array<[string, string]> = [["EUR", "USD"], ["GBP", "USD"], ["USD", "JPY"], ["USD", "CAD"], ["USD", "CHF"], ["AUD", "USD"], ["EUR", "GBP"], ["EUR", "JPY"], ["EUR", "CHF"], ["EUR", "CAD"], ["EUR", "AUD"], ["AUD", "JPY"]]; | ||||
| 10 | +const PAIRS = [...CRYPTO_PAIRS, ...FIAT_PAIRS]; | ||||
| 6 | const SYMBOLS = PAIRS.map(([b, q]) => `${b}/${q}`); | 11 | const SYMBOLS = PAIRS.map(([b, q]) => `${b}/${q}`); | ||
| 12 | +const fiatSeeds: ProposedInstrument[] = FIAT_PAIRS.map(([b, q]) => ({ symbol: `${b}/${q}`, hint: planPair(b, q, "kraken").hint })); | ||||
| 7 | 13 | ||||
| 8 | /** Kraken WebSocket API v2, `ticker` channel (event trigger: trades). No per-message timestamp → connector receive time. */ | 14 | /** Kraken WebSocket API v2, `ticker` channel (event trigger: trades). No per-message timestamp → connector receive time. */ | ||
| 9 | export const krakenWs = defineConnector({ | 15 | export const krakenWs = defineConnector({ | ||
@@ -20,16 +26,16 @@ export const krakenWs = defineConnector({ | |||||
| 20 | expectedLatencyMs: 400, | 26 | expectedLatencyMs: 400, | ||
| 21 | supportsStreaming: true, | 27 | supportsStreaming: true, | ||
| 22 | supportsHistorical: false, | 28 | supportsHistorical: false, | ||
| 23 | − assetClasses: ["CRYPTO"], | 29 | + assetClasses: ["CRYPTO", "FOREX"], | ||
| 24 | exchanges: ["kraken"], | 30 | exchanges: ["kraken"], | ||
| 25 | homepage: "https://docs.kraken.com/api/docs/websocket-v2/ticker", | 31 | homepage: "https://docs.kraken.com/api/docs/websocket-v2/ticker", | ||
| 26 | − description: "Public Kraken WebSocket v2 ticker channel (best bid/ask, last trade, 24h volume/VWAP/high/low) for the major spot pairs.", | 32 | + description: "Public Kraken WebSocket v2 ticker channel (best bid/ask, last trade, 24h volume/VWAP/high/low) for the major crypto spot pairs and Kraken's 12 fiat/fiat markets (EUR/USD, USD/JPY, GBP/USD, USD/CAD…) — a live, keyless FX source.", | ||
| 27 | rightsNotes: "Public market data displayed with attribution to Kraken.", | 33 | rightsNotes: "Public market data displayed with attribution to Kraken.", | ||
| 28 | termsUrl: "https://www.kraken.com/legal", | 34 | termsUrl: "https://www.kraken.com/legal", | ||
| 29 | sourceFamily: "kraken", | 35 | sourceFamily: "kraken", | ||
| 30 | enabled: true, | 36 | enabled: true, | ||
| 31 | }, | 37 | }, | ||
| 32 | − seeds: cryptoSeeds(PAIRS, "kraken", (b, q) => `${b}/${q}`), | 38 | + seeds: [...cryptoSeeds(CRYPTO_PAIRS, "kraken", (b, q) => `${b}/${q}`), ...fiatSeeds], | ||
| 33 | defaultSymbols: SYMBOLS, | 39 | defaultSymbols: SYMBOLS, | ||
| 34 | async start(ctx) { | 40 | async start(ctx) { | ||
| 35 | const ws = ctx.openWebSocket(WS_URL, { | 41 | const ws = ctx.openWebSocket(WS_URL, { | ||
@@ -59,7 +65,7 @@ export const krakenWs = defineConnector({ | |||||
| 59 | const [base, quote] = t.symbol.split("/"); | 65 | const [base, quote] = t.symbol.split("/"); | ||
| 60 | if (!base || !quote) continue; | 66 | if (!base || !quote) continue; | ||
| 61 | observations.push( | 67 | observations.push( | ||
| 62 | − ...tickerObservations(t.symbol, base, quote, "kraken", { last: t.last, high: t.high, low: t.low, bid: t.bid, ask: t.ask, bidSize: t.bid_qty, askSize: t.ask_qty, volume: t.volume, vwap: t.vwap }, { | 68 | + ...pairObservations(t.symbol, base, quote, "kraken", { last: t.last, high: t.high, low: t.low, bid: t.bid, ask: t.ask, bidSize: t.bid_qty, askSize: t.ask_qty, volume: t.volume, vwap: t.vwap }, { | ||
| 63 | sourceTimestamp: null, | 69 | sourceTimestamp: null, | ||
| 64 | timestampTrust: "CONNECTOR", | 70 | timestampTrust: "CONNECTOR", | ||
| 65 | rightsStatus: "PUBLIC_ATTRIBUTED", | 71 | rightsStatus: "PUBLIC_ATTRIBUTED", | ||
added
connectors/src/kucoin-ws/README.md
+8 −0
@@ -0,0 +1,8 @@ | |||||
| 1 | +# kucoin-ws | ||||
| 2 | + | ||||
| 3 | +KuCoin spot public WebSocket, topic `/market/ticker:<SYMBOLS>`. The endpoint + token come from `POST https://api.kucoin.com/api/v1/bullet-public` (no account, no key). | ||||
| 4 | + | ||||
| 5 | +- **Type**: WEBSOCKET · **Rights**: PUBLIC_ATTRIBUTED · **Real-time**: REALTIME (tick `time`) | ||||
| 6 | +- **Fields**: LAST_PRICE, BID/ASK with sizes. No 24 h stats in this topic. | ||||
| 7 | +- **Heartbeat**: `{"type":"ping"}` at the server-advertised `pingInterval` minus 3 s. | ||||
| 8 | +- **Pairs**: USDT majors + EUR-USDT (FX proxy). | ||||
added
connectors/src/kucoin-ws/fixtures/ticker.json
+1 −0
@@ -0,0 +1 @@ | |||||
| 1 | +{"topic":"/market/ticker:BTC-USDT","type":"message","subject":"trade.ticker","data":{"bestAsk":"77225.2","bestAskSize":"0.35133999","bestBid":"77225.1","bestBidSize":"0.37973782","price":"77225.1","sequence":"37014680999","size":"0.00002491","time":1789274087087}} | ||||
added
connectors/src/kucoin-ws/index.ts
+85 −0
@@ -0,0 +1,85 @@ | |||||
| 1 | +import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | ||||
| 2 | +import { parseTimestamp } from "@market-atlas/market-model"; | ||||
| 3 | +import { MAJOR_BASES } from "../_shared/crypto.js"; | ||||
| 4 | +import { pairObservations, planPair } from "../_shared/pairs.js"; | ||||
| 5 | + | ||||
| 6 | +const BULLET_URL = "https://api.kucoin.com/api/v1/bullet-public"; | ||||
| 7 | +const SYMBOLS = [...MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => `${b}-USDT`), "EUR-USDT"]; | ||||
| 8 | + | ||||
| 9 | +/** | ||||
| 10 | + * KuCoin spot ticker (`/market/ticker:<symbols>`). The public WebSocket endpoint is obtained through a | ||||
| 11 | + * keyless "bullet" token request (no account), then one connection carries every symbol. | ||||
| 12 | + */ | ||||
| 13 | +export const kucoinWs = defineConnector({ | ||||
| 14 | + metadata: { | ||||
| 15 | + id: "kucoin-ws", | ||||
| 16 | + name: "KuCoin — spot ticker", | ||||
| 17 | + version: "1.0.0", | ||||
| 18 | + sourceId: "kucoin", | ||||
| 19 | + organization: "KuCoin", | ||||
| 20 | + sourceType: "WEBSOCKET", | ||||
| 21 | + jurisdiction: "SC", | ||||
| 22 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 23 | + realtimeStatus: "REALTIME", | ||||
| 24 | + expectedLatencyMs: 500, | ||||
| 25 | + supportsStreaming: true, | ||||
| 26 | + supportsHistorical: false, | ||||
| 27 | + assetClasses: ["CRYPTO", "FOREX"], | ||||
| 28 | + exchanges: ["kucoin"], | ||||
| 29 | + homepage: "https://www.kucoin.com/docs/websocket/spot-trading/public-channels/ticker", | ||||
| 30 | + description: "Public KuCoin spot ticker topic (last price, best bid/ask with sizes) for USDT majors and the EUR/USDT stablecoin market. Endpoint discovered via the keyless public bullet token.", | ||||
| 31 | + rightsNotes: "Public market data displayed with attribution to KuCoin.", | ||||
| 32 | + termsUrl: "https://www.kucoin.com/legal/terms-of-use", | ||||
| 33 | + sourceFamily: "kucoin", | ||||
| 34 | + enabled: true, | ||||
| 35 | + }, | ||||
| 36 | + seeds: SYMBOLS.map((s) => { | ||||
| 37 | + const [b, q] = s.split("-") as [string, string]; | ||||
| 38 | + return { symbol: s, hint: planPair(b, q, "kucoin").hint }; | ||||
| 39 | + }), | ||||
| 40 | + defaultSymbols: SYMBOLS, | ||||
| 41 | + rateLimits: { "api.kucoin.com": 1 }, | ||||
| 42 | + async start(ctx) { | ||||
| 43 | + const res = await ctx.http.request(BULLET_URL, { method: "POST", conditional: false, timeoutMs: 15_000, headers: { "content-type": "application/json" } }); | ||||
| 44 | + const bullet = JSON.parse(res.text) as { data?: { token?: string; instanceServers?: Array<{ endpoint: string; pingInterval?: number }> } }; | ||||
| 45 | + const server = bullet.data?.instanceServers?.[0]; | ||||
| 46 | + const token = bullet.data?.token; | ||||
| 47 | + if (!server || !token) throw new Error("kucoin bullet token unavailable"); | ||||
| 48 | + const url = `${server.endpoint}?token=${encodeURIComponent(token)}&connectId=market-atlas`; | ||||
| 49 | + const ws = ctx.openWebSocket(url, { | ||||
| 50 | + label: "kucoin", | ||||
| 51 | + staleAfterMs: 60_000, | ||||
| 52 | + heartbeat: { intervalMs: Math.max(5000, Math.min(server.pingInterval ?? 18_000, 30_000) - 3000), message: JSON.stringify({ id: "hb", type: "ping" }) }, | ||||
| 53 | + onOpen: (sock) => sock.send({ id: "1", type: "subscribe", topic: `/market/ticker:${ctx.watchedSymbols().join(",")}`, response: true }), | ||||
| 54 | + onMessage: (data) => { | ||||
| 55 | + let msg: any; | ||||
| 56 | + try { | ||||
| 57 | + msg = JSON.parse(data); | ||||
| 58 | + } catch { | ||||
| 59 | + return; | ||||
| 60 | + } | ||||
| 61 | + if (msg?.type === "message" && typeof msg.topic === "string" && msg.topic.startsWith("/market/ticker:") && msg.data) ctx.emit(raw("kucoin-ws", "kucoin", "ticker", msg)); | ||||
| 62 | + else if (msg?.type === "error") ctx.reportError(new Error(String(msg.data ?? "kucoin error"))); | ||||
| 63 | + }, | ||||
| 64 | + }); | ||||
| 65 | + ws.connect(); | ||||
| 66 | + }, | ||||
| 67 | + normalize(r): NormalizedBatch { | ||||
| 68 | + const m = r.payload as { topic?: string; data?: Record<string, unknown> }; | ||||
| 69 | + const d = m.data; | ||||
| 70 | + if (r.kind !== "ticker" || typeof m.topic !== "string" || !d) return { observations: [] }; | ||||
| 71 | + const symbol = m.topic.slice("/market/ticker:".length); | ||||
| 72 | + const [base, quote] = symbol.split("-"); | ||||
| 73 | + if (!base || !quote) return { observations: [] }; | ||||
| 74 | + return { | ||||
| 75 | + observations: pairObservations(symbol, base, quote, "kucoin", { last: d.price, bid: d.bestBid, bidSize: d.bestBidSize, ask: d.bestAsk, askSize: d.bestAskSize }, { | ||||
| 76 | + sourceTimestamp: parseTimestamp(d.time), | ||||
| 77 | + timestampTrust: "EXCHANGE", | ||||
| 78 | + sequence: typeof d.sequence === "string" ? d.sequence : null, | ||||
| 79 | + rightsStatus: "PUBLIC_ATTRIBUTED", | ||||
| 80 | + realtimeStatus: "REALTIME", | ||||
| 81 | + }), | ||||
| 82 | + }; | ||||
| 83 | + }, | ||||
| 84 | + fixturesDir: "fixtures", | ||||
| 85 | +}); | ||||
added
connectors/src/nasdaq-quote-api/README.md
+8 −0
@@ -0,0 +1,8 @@ | |||||
| 1 | +# nasdaq-quote-api — validation only | ||||
| 2 | + | ||||
| 3 | +`https://api.nasdaq.com/api/quote/<SYMBOL>/info?assetclass=stocks|etf` — the JSON behind nasdaq.com quote pages (delayed). | ||||
| 4 | + | ||||
| 5 | +- **Type**: XHR · **Rights**: PUBLIC_RESTRICTED_REDISTRIBUTION → **validator role**: the consensus engine excludes it from the canonical value (`reason: validation_only`) but counts its agreement (`validator_count`) and flags disagreement (`validator_disagrees`). Values are never shown or streamed. | ||||
| 6 | +- **Family**: `nasdaq` — independent from Cboe, so a Cboe/Nasdaq agreement is a real cross-check for US equities. | ||||
| 7 | +- **Timestamps**: `lastTradeTimestamp` like "Sep 12, 2026 4:00 PM ET" or "Sep 10, 2026" (→ 16:00 ET) parsed with `parseNasdaqTime`. | ||||
| 8 | +- **Schedule**: every 5 min while Nasdaq is open (0.5 req/s → full universe in ~2.5 min), hourly closed, every 6 h on weekends. | ||||
added
connectors/src/nasdaq-quote-api/fixtures/info.json
+1 −0
@@ -0,0 +1 @@ | |||||
| 1 | +{"data":{"symbol":"AAPL","companyName":"Apple Inc. Common Stock","stockType":"Common Stock","exchange":"NASDAQ-GS","isNasdaqListed":true,"isNasdaq100":true,"isHeld":false,"primaryData":{"lastSalePrice":"$332.27","netChange":"+5.70","percentageChange":"+1.75%","deltaIndicator":"up","lastTradeTimestamp":"Sep 10, 2026 4:00 PM ET","isRealTime":false,"bidPrice":"$332.20","askPrice":"$332.35","bidSize":"100","askSize":"200","volume":"50,716,997","currency":null},"secondaryData":null,"marketStatus":"Closed","assetClass":"STOCKS"},"message":null,"status":{"rCode":200,"bCodeMessage":null,"developerMessage":null}} | ||||
added
connectors/src/nasdaq-quote-api/index.ts
+95 −0
@@ -0,0 +1,95 @@ | |||||
| 1 | +import { defineConnector, parseNumber, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | ||||
| 2 | +import type { NormalizedObservation, RawObservation } from "@market-atlas/market-model"; | ||||
| 3 | +import { zonedTimeToUtc } from "@market-atlas/market-model"; | ||||
| 4 | +import { US_EQUITIES, US_ETFS } from "../_shared/us-universe.js"; | ||||
| 5 | + | ||||
| 6 | +const BASE = "https://api.nasdaq.com/api/quote"; | ||||
| 7 | +const ET = "America/New_York"; | ||||
| 8 | +const SYMBOLS = [...US_EQUITIES.map(([s]) => s), ...US_ETFS.map(([s]) => s)]; | ||||
| 9 | +const MONTHS: Record<string, number> = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 }; | ||||
| 10 | + | ||||
| 11 | +/** | ||||
| 12 | + * Nasdaq.com public quote JSON (the XHR behind nasdaq.com quote pages; delayed). Rights classified as | ||||
| 13 | + * PUBLIC_RESTRICTED_REDISTRIBUTION → Market Atlas uses it as a *validator* only: it confirms or contests | ||||
| 14 | + * the canonical price and feeds confidence, but its values are never redistributed. | ||||
| 15 | + */ | ||||
| 16 | +export const nasdaqQuoteApi = defineConnector({ | ||||
| 17 | + metadata: { | ||||
| 18 | + id: "nasdaq-quote-api", | ||||
| 19 | + name: "Nasdaq.com — delayed quote (validation only)", | ||||
| 20 | + version: "1.0.0", | ||||
| 21 | + sourceId: "nasdaq-com", | ||||
| 22 | + organization: "Nasdaq, Inc.", | ||||
| 23 | + sourceType: "XHR", | ||||
| 24 | + jurisdiction: "US", | ||||
| 25 | + rightsStatus: "PUBLIC_RESTRICTED_REDISTRIBUTION", | ||||
| 26 | + realtimeStatus: "DELAYED", | ||||
| 27 | + expectedLatencyMs: 15 * 60_000, | ||||
| 28 | + supportsStreaming: false, | ||||
| 29 | + supportsHistorical: false, | ||||
| 30 | + assetClasses: ["EQUITY", "ETF"], | ||||
| 31 | + exchanges: ["xnas", "xnys", "arcx"], | ||||
| 32 | + homepage: "https://www.nasdaq.com/market-activity/stocks", | ||||
| 33 | + description: "Delayed last sale, bid/ask and volume from nasdaq.com's public quote JSON for the curated US universe. Used for cross-validation of the canonical price only (independent Nasdaq family); values are withheld from public responses.", | ||||
| 34 | + rightsNotes: "Nasdaq.com terms restrict redistribution: internal validation only, never displayed or streamed.", | ||||
| 35 | + termsUrl: "https://www.nasdaq.com/terms-of-use", | ||||
| 36 | + sourceFamily: "nasdaq", | ||||
| 37 | + enabled: true, | ||||
| 38 | + }, | ||||
| 39 | + defaultSymbols: SYMBOLS, | ||||
| 40 | + rateLimits: { "api.nasdaq.com": 0.5 }, | ||||
| 41 | + schedule: { openMs: 5 * 60_000, closedMs: 60 * 60_000, weekendMs: 6 * 60 * 60_000, exchangeId: "xnas" }, | ||||
| 42 | + async poll(ctx) { | ||||
| 43 | + const out: RawObservation[] = []; | ||||
| 44 | + const etfs = new Set(US_ETFS.map(([s]) => s)); | ||||
| 45 | + for (const sym of ctx.watchedSymbols()) { | ||||
| 46 | + try { | ||||
| 47 | + const { data, response } = await ctx.http.getJson<Record<string, unknown>>(`${BASE}/${encodeURIComponent(sym)}/info?assetclass=${etfs.has(sym) ? "etf" : "stocks"}`, { | ||||
| 48 | + timeoutMs: 30_000, | ||||
| 49 | + // The Akamai front of api.nasdaq.com only answers browser-like clients. | ||||
| 50 | + headers: { accept: "application/json, text/plain, */*", "accept-language": "en-US,en;q=0.9", origin: "https://www.nasdaq.com", referer: "https://www.nasdaq.com/", "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36" }, | ||||
| 51 | + }); | ||||
| 52 | + if (response.notModified) continue; | ||||
| 53 | + out.push(raw("nasdaq-quote-api", "nasdaq-com", "info", data, { symbol: sym })); | ||||
| 54 | + } catch (err) { | ||||
| 55 | + ctx.reportError(err, { symbol: sym }); | ||||
| 56 | + } | ||||
| 57 | + } | ||||
| 58 | + return out; | ||||
| 59 | + }, | ||||
| 60 | + normalize(r): NormalizedBatch { | ||||
| 61 | + const p = r.payload as { data?: { symbol?: string; primaryData?: Record<string, unknown>; marketStatus?: string } }; | ||||
| 62 | + const d = p?.data; | ||||
| 63 | + const pd = d?.primaryData; | ||||
| 64 | + if (r.kind !== "info" || !d || typeof d.symbol !== "string" || !pd) return { observations: [] }; | ||||
| 65 | + const ts = parseNasdaqTime(String(pd.lastTradeTimestamp ?? "")); | ||||
| 66 | + const observations: NormalizedObservation[] = []; | ||||
| 67 | + const push = (field: NormalizedObservation["field"], v: unknown, type: NormalizedObservation["observationType"]) => { | ||||
| 68 | + const n = parseNumber(typeof v === "string" ? v.replace(/^\$/, "") : v); | ||||
| 69 | + if (n == null || (field !== "VOLUME" && n <= 0)) return; | ||||
| 70 | + observations.push({ symbol: d.symbol!, field, value: n, currency: "USD", observationType: type, sourceTimestamp: ts, timestampTrust: ts ? "SOURCE" : "CONNECTOR", rightsStatus: "PUBLIC_RESTRICTED_REDISTRIBUTION", realtimeStatus: "DELAYED", meta: { market_status: d.marketStatus, is_real_time: pd.isRealTime } }); | ||||
| 71 | + }; | ||||
| 72 | + push("LAST_PRICE", pd.lastSalePrice, "TRADE"); | ||||
| 73 | + push("BID", pd.bidPrice, "QUOTE"); | ||||
| 74 | + push("ASK", pd.askPrice, "QUOTE"); | ||||
| 75 | + push("VOLUME", pd.volume, "TRADE"); | ||||
| 76 | + push("CHANGE", pd.netChange, "TRADE"); | ||||
| 77 | + return { observations }; | ||||
| 78 | + }, | ||||
| 79 | + fixturesDir: "fixtures", | ||||
| 80 | +}); | ||||
| 81 | + | ||||
| 82 | +/** "Sep 12, 2026 4:00 PM ET" or "Sep 10, 2026" (closing) → UTC ms. */ | ||||
| 83 | +export function parseNasdaqTime(s: string): number | null { | ||||
| 84 | + const m = s.match(/^([A-Za-z]{3})\.?\s+(\d{1,2}),\s*(\d{4})(?:\s+(\d{1,2}):(\d{2})\s*(AM|PM))?/i); | ||||
| 85 | + if (!m) return null; | ||||
| 86 | + const mo = MONTHS[m[1]!.toLowerCase()]; | ||||
| 87 | + if (!mo) return null; | ||||
| 88 | + let h = 16; | ||||
| 89 | + let mi = 0; | ||||
| 90 | + if (m[4]) { | ||||
| 91 | + h = Number(m[4]) % 12 + (m[6]!.toUpperCase() === "PM" ? 12 : 0); | ||||
| 92 | + mi = Number(m[5]); | ||||
| 93 | + } | ||||
| 94 | + return zonedTimeToUtc(`${m[3]}-${String(mo).padStart(2, "0")}-${m[2]!.padStart(2, "0")}T${String(h).padStart(2, "0")}:${String(mi).padStart(2, "0")}:00`, ET); | ||||
| 95 | +} | ||||
modified
connectors/src/okx-ws/index.ts
+9 −5
@@ -1,9 +1,13 @@ | |||||
| 1 | import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | 1 | import { defineConnector, raw, type NormalizedBatch } from "@market-atlas/connector-sdk"; | ||
| 2 | import { parseTimestamp } from "@market-atlas/market-model"; | 2 | import { parseTimestamp } from "@market-atlas/market-model"; | ||
| 3 | −import { MAJOR_BASES, cryptoSeeds, tickerObservations } from "../_shared/crypto.js"; | 3 | +import { MAJOR_BASES, cryptoSeeds } from "../_shared/crypto.js"; | ||
| 4 | +import { pairObservations } from "../_shared/pairs.js"; | ||||
| 4 | 5 | ||||
| 5 | const WS_URL = "wss://ws.okx.com:8443/ws/v5/public"; | 6 | const WS_URL = "wss://ws.okx.com:8443/ws/v5/public"; | ||
| 6 | −const PAIRS: Array<[string, string]> = MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USDT"] as [string, string]).concat([["TRX", "USDT"]]); | 7 | +const CRYPTO_PAIRS: Array<[string, string]> = MAJOR_BASES.filter((b) => b !== "PAXG").map((b) => [b, "USDT"] as [string, string]).concat([["TRX", "USDT"]]); | ||
| 8 | +/** USDT markets against fiat → live FX proxies (STABLECOIN_PROXY). */ | ||||
| 9 | +const FX_PROXY_PAIRS: Array<[string, string]> = [["USDT", "EUR"], ["USDT", "AUD"], ["USDT", "BRL"], ["USDT", "SGD"], ["USDT", "TRY"], ["USDT", "AED"]]; | ||||
| 10 | +const PAIRS = [...CRYPTO_PAIRS, ...FX_PROXY_PAIRS]; | ||||
| 7 | const SYMBOLS = PAIRS.map(([b, q]) => `${b}-${q}`); | 11 | const SYMBOLS = PAIRS.map(([b, q]) => `${b}-${q}`); | ||
| 8 | 12 | ||||
| 9 | /** OKX public WebSocket v5, `tickers` channel. Requires a text "ping" at least every 30 s. */ | 13 | /** OKX public WebSocket v5, `tickers` channel. Requires a text "ping" at least every 30 s. */ | ||
@@ -21,7 +25,7 @@ export const okxWs = defineConnector({ | |||||
| 21 | expectedLatencyMs: 500, | 25 | expectedLatencyMs: 500, | ||
| 22 | supportsStreaming: true, | 26 | supportsStreaming: true, | ||
| 23 | supportsHistorical: false, | 27 | supportsHistorical: false, | ||
| 24 | − assetClasses: ["CRYPTO"], | 28 | + assetClasses: ["CRYPTO", "FOREX"], | ||
| 25 | exchanges: ["okx"], | 29 | exchanges: ["okx"], | ||
| 26 | homepage: "https://www.okx.com/docs-v5/en/#public-data-websocket-tickers-channel", | 30 | homepage: "https://www.okx.com/docs-v5/en/#public-data-websocket-tickers-channel", | ||
| 27 | description: "Public OKX v5 WebSocket tickers channel (last, bid/ask with sizes, 24h open/high/low/volume) for USDT spot majors. Exchange timestamp `ts`.", | 31 | description: "Public OKX v5 WebSocket tickers channel (last, bid/ask with sizes, 24h open/high/low/volume) for USDT spot majors. Exchange timestamp `ts`.", | ||
@@ -30,7 +34,7 @@ export const okxWs = defineConnector({ | |||||
| 30 | sourceFamily: "okx", | 34 | sourceFamily: "okx", | ||
| 31 | enabled: true, | 35 | enabled: true, | ||
| 32 | }, | 36 | }, | ||
| 33 | − seeds: cryptoSeeds(PAIRS, "okx", (b, q) => `${b}-${q}`), | 37 | + seeds: cryptoSeeds(CRYPTO_PAIRS, "okx", (b, q) => `${b}-${q}`), | ||
| 34 | defaultSymbols: SYMBOLS, | 38 | defaultSymbols: SYMBOLS, | ||
| 35 | async start(ctx) { | 39 | async start(ctx) { | ||
| 36 | const ws = ctx.openWebSocket(WS_URL, { | 40 | const ws = ctx.openWebSocket(WS_URL, { | ||
@@ -61,7 +65,7 @@ export const okxWs = defineConnector({ | |||||
| 61 | const [base, quote] = t.instId.split("-"); | 65 | const [base, quote] = t.instId.split("-"); | ||
| 62 | if (!base || !quote) continue; | 66 | if (!base || !quote) continue; | ||
| 63 | observations.push( | 67 | observations.push( | ||
| 64 | − ...tickerObservations(t.instId, base, quote, "okx", { last: t.last, open: t.open24h, high: t.high24h, low: t.low24h, bid: t.bidPx, ask: t.askPx, bidSize: t.bidSz, askSize: t.askSz, volume: t.vol24h }, { | 68 | + ...pairObservations(t.instId, base, quote, "okx", { last: t.last, open: t.open24h, high: t.high24h, low: t.low24h, bid: t.bidPx, ask: t.askPx, bidSize: t.bidSz, askSize: t.askSz, volume: t.vol24h }, { | ||
| 65 | sourceTimestamp: parseTimestamp(t.ts), | 69 | sourceTimestamp: parseTimestamp(t.ts), | ||
| 66 | timestampTrust: "EXCHANGE", | 70 | timestampTrust: "EXCHANGE", | ||
| 67 | rightsStatus: "PUBLIC_ATTRIBUTED", | 71 | rightsStatus: "PUBLIC_ATTRIBUTED", | ||
modified
connectors/src/sources.ts
+8 −0
@@ -10,6 +10,14 @@ export const SOURCES: SourceRecord[] = [ | |||||
| 10 | { id: "kraken", name: "Kraken", organization: "Payward, Inc.", sourceType: "WEBSOCKET", homepage: "https://www.kraken.com", jurisdiction: "US", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "kraken", category: "EXCHANGE", enabled: true }, | 10 | { id: "kraken", name: "Kraken", organization: "Payward, Inc.", sourceType: "WEBSOCKET", homepage: "https://www.kraken.com", jurisdiction: "US", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "kraken", category: "EXCHANGE", enabled: true }, | ||
| 11 | { id: "binance", name: "Binance", organization: "Binance", sourceType: "WEBSOCKET", homepage: "https://www.binance.com", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "binance", category: "EXCHANGE", enabled: true }, | 11 | { id: "binance", name: "Binance", organization: "Binance", sourceType: "WEBSOCKET", homepage: "https://www.binance.com", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "binance", category: "EXCHANGE", enabled: true }, | ||
| 12 | { id: "okx", name: "OKX", organization: "OKX", sourceType: "WEBSOCKET", homepage: "https://www.okx.com", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "okx", category: "EXCHANGE", enabled: true }, | 12 | { id: "okx", name: "OKX", organization: "OKX", sourceType: "WEBSOCKET", homepage: "https://www.okx.com", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "okx", category: "EXCHANGE", enabled: true }, | ||
| 13 | + { id: "bitstamp", name: "Bitstamp", organization: "Bitstamp Ltd", sourceType: "WEBSOCKET", homepage: "https://www.bitstamp.net", jurisdiction: "GB", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "bitstamp", category: "EXCHANGE", enabled: true }, | ||||
| 14 | + { id: "gemini", name: "Gemini", organization: "Gemini Trust Company, LLC", sourceType: "WEBSOCKET", homepage: "https://www.gemini.com", jurisdiction: "US", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "gemini", category: "EXCHANGE", enabled: true }, | ||||
| 15 | + { id: "bitfinex", name: "Bitfinex", organization: "iFinex Inc.", sourceType: "WEBSOCKET", homepage: "https://www.bitfinex.com", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "bitfinex", category: "EXCHANGE", enabled: true }, | ||||
| 16 | + { id: "bybit", name: "Bybit", organization: "Bybit", sourceType: "WEBSOCKET", homepage: "https://www.bybit.com", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "bybit", category: "EXCHANGE", enabled: true }, | ||||
| 17 | + { id: "gate", name: "Gate", organization: "Gate Technology Inc.", sourceType: "WEBSOCKET", homepage: "https://www.gate.io", jurisdiction: null, rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "gate", category: "EXCHANGE", enabled: true }, | ||||
| 18 | + { id: "cryptocom", name: "Crypto.com Exchange", organization: "Crypto.com", sourceType: "WEBSOCKET", homepage: "https://crypto.com/exchange", jurisdiction: "SG", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "cryptocom", category: "EXCHANGE", enabled: true }, | ||||
| 19 | + { id: "kucoin", name: "KuCoin", organization: "KuCoin", sourceType: "WEBSOCKET", homepage: "https://www.kucoin.com", jurisdiction: "SC", rightsStatus: "PUBLIC_ATTRIBUTED", realtimeStatus: "REALTIME", family: "kucoin", category: "EXCHANGE", enabled: true }, | ||||
| 20 | + { id: "nasdaq-com", name: "Nasdaq.com — delayed quotes (validation only)", organization: "Nasdaq, Inc.", sourceType: "XHR", homepage: "https://www.nasdaq.com/market-activity", jurisdiction: "US", rightsStatus: "PUBLIC_RESTRICTED_REDISTRIBUTION", realtimeStatus: "DELAYED", family: "nasdaq", category: "EXCHANGE", enabled: true }, | ||||
| 13 | { id: "cboe", name: "Cboe Global Markets — delayed quotes", organization: "Cboe Global Markets", sourceType: "XHR", homepage: "https://www.cboe.com", jurisdiction: "US", rightsStatus: "DELAYED", realtimeStatus: "DELAYED", family: "cboe", category: "EXCHANGE", enabled: true }, | 21 | { id: "cboe", name: "Cboe Global Markets — delayed quotes", organization: "Cboe Global Markets", sourceType: "XHR", homepage: "https://www.cboe.com", jurisdiction: "US", rightsStatus: "DELAYED", realtimeStatus: "DELAYED", family: "cboe", category: "EXCHANGE", enabled: true }, | ||
| 14 | { id: "ecb-frankfurter", name: "ECB euro foreign exchange reference rates (via Frankfurter)", organization: "European Central Bank", sourceType: "OFFICIAL_API", homepage: "https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html", jurisdiction: "EU", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", family: "ecb", category: "CENTRAL_BANK", enabled: true }, | 22 | { id: "ecb-frankfurter", name: "ECB euro foreign exchange reference rates (via Frankfurter)", organization: "European Central Bank", sourceType: "OFFICIAL_API", homepage: "https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html", jurisdiction: "EU", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", family: "ecb", category: "CENTRAL_BANK", enabled: true }, | ||
| 15 | { id: "bank-of-canada", name: "Bank of Canada Valet API", organization: "Bank of Canada", sourceType: "OFFICIAL_API", homepage: "https://www.bankofcanada.ca/valet/docs", jurisdiction: "CA", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", family: "bank-of-canada", category: "CENTRAL_BANK", enabled: true }, | 23 | { id: "bank-of-canada", name: "Bank of Canada Valet API", organization: "Bank of Canada", sourceType: "OFFICIAL_API", homepage: "https://www.bankofcanada.ca/valet/docs", jurisdiction: "CA", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", family: "bank-of-canada", category: "CENTRAL_BANK", enabled: true }, | ||
modified
connectors/src/us-treasury-yield-curve/index.ts
+1 −1
@@ -82,7 +82,7 @@ export const usTreasuryYieldCurve = defineConnector({ | |||||
| 82 | const v = last.values[key]; | 82 | const v = last.values[key]; | ||
| 83 | if (v == null) continue; | 83 | if (v == null) continue; | ||
| 84 | const ts = zonedTimeToUtc(`${last.date!.slice(0, 10)}T15:30:00`, "America/New_York"); | 84 | const ts = zonedTimeToUtc(`${last.date!.slice(0, 10)}T15:30:00`, "America/New_York"); | ||
| 85 | − const base = { symbol, instrumentHint: hint(symbol, name), currency: "USD", sourceTimestamp: ts, timestampTrust: "SOURCE" as const, rightsStatus: "OFFICIAL_OPEN_DATA" as const, realtimeStatus: "END_OF_DAY" as const, meta: { date: last.date!.slice(0, 10) } }; | 85 | + const base = { symbol, instrumentHint: hint(symbol, name), currency: "USD", observationType: "REFERENCE_RATE" as const, sourceTimestamp: ts, timestampTrust: "SOURCE" as const, rightsStatus: "OFFICIAL_OPEN_DATA" as const, realtimeStatus: "END_OF_DAY" as const, meta: { date: last.date!.slice(0, 10) } }; | ||
| 86 | observations.push({ ...base, field: "YIELD", value: v }, { ...base, field: "LAST_PRICE", value: v }); | 86 | observations.push({ ...base, field: "YIELD", value: v }, { ...base, field: "LAST_PRICE", value: v }); | ||
| 87 | const pv = prev?.values[key]; | 87 | const pv = prev?.values[key]; | ||
| 88 | if (pv != null) observations.push({ ...base, field: "PREVIOUS_CLOSE", value: pv, meta: { date: prev!.date!.slice(0, 10) } }); | 88 | if (pv != null) observations.push({ ...base, field: "PREVIOUS_CLOSE", value: pv, meta: { date: prev!.date!.slice(0, 10) } }); | ||
modified
docs/API.md
+5 −1
@@ -8,6 +8,8 @@ Quote object (`publicQuote`): `instrument_id, symbol, name, asset_class, exchang | |||||
| 8 | change, change_percent, volume, bid, ask, currency, source_count, dispersion_bps, confidence, freshness_ms, data_status, market_state, | 8 | change, change_percent, volume, bid, ask, currency, source_count, dispersion_bps, confidence, freshness_ms, data_status, market_state, | ||
| 9 | rights_status, withheld, updated_at, source_timestamp, session_high, session_low`. | 9 | rights_status, withheld, updated_at, source_timestamp, session_high, session_low`. | ||
| 10 | `data_status` ∈ `REALTIME | DELAYED | END_OF_DAY | AT_CLOSE | STALE | WITHHELD` — never present STALE/AT_CLOSE as live. | 10 | `data_status` ∈ `REALTIME | DELAYED | END_OF_DAY | AT_CLOSE | STALE | WITHHELD` — never present STALE/AT_CLOSE as live. | ||
| 11 | +Quotes also carry `observation_count`, `proxy_count`, `validator_count`, `comparability` (LIVE/FIX/EOD); provenance contributions carry | ||||
| 12 | +`observation_type`, `delta_bps`, `reason` ∈ stale | not_comparable | temporal_mismatch | validation_only | validator_disagrees | outlier. | ||||
| 11 | 13 | ||||
| 12 | | Method & path | Purpose | | 14 | | Method & path | Purpose | | ||
| 13 | |---|---| | 15 | |---|---| | ||
@@ -29,7 +31,9 @@ rights_status, withheld, updated_at, source_timestamp, session_high, session_low | |||||
| 29 | | `GET /exchanges` · `GET /exchanges/:id` | venues, status/next transition, holidays, breadth, movers, events | | 31 | | `GET /exchanges` · `GET /exchanges/:id` | venues, status/next transition, holidays, breadth, movers, events | | ||
| 30 | | `GET /countries` · `GET /countries/:code` | country atlas page data | | 32 | | `GET /countries` · `GET /countries/:code` | country atlas page data | | ||
| 31 | | `GET /compare?ids=a,b,c&resolution=1d&limit` | normalized series, return/vol/drawdown, correlations | | 33 | | `GET /compare?ids=a,b,c&resolution=1d&limit` | normalized series, return/vol/drawdown, correlations | | ||
| 32 | −| `GET /sources` · `GET /connectors` · `GET /data-health` | source directory, public connector table, data-health dashboard | | 34 | +| `GET /sources` · `GET /connectors` · `GET /data-health` | source directory (with `role` contributor/validator and `likely_shared_upstream_with`), public connector table, data-health dashboard | | ||
| 35 | +| `GET /coverage?asset_class&tier&limit` · `GET /coverage/:id` | Source Coverage Engine: redundancy histogram (≥5/≥3/≥2/1 families), weighted score, tier attainment, expansion queue, inferred shared upstreams; per-instrument coverage | | ||||
| 36 | +| `GET /admin/lineage` | all statistically compared source pairs with similarity | | ||||
| 33 | | `GET /stream` (WebSocket) · `GET /sse?channels=` | live stream — see below | | 37 | | `GET /stream` (WebSocket) · `GET /sse?channels=` | live stream — see below | | ||
| 34 | | `GET /admin/overview|connectors|connectors/:id|schema-changes|divergence|storage|raw?ref=` · `POST /admin/connectors/:id/pause|resume|restart|test` · `POST /admin/connectors/:id/schema-changes/:changeId/ack` · `POST /admin/discovery {url}` | admin console | | 38 | | `GET /admin/overview|connectors|connectors/:id|schema-changes|divergence|storage|raw?ref=` · `POST /admin/connectors/:id/pause|resume|restart|test` · `POST /admin/connectors/:id/schema-changes/:changeId/ack` · `POST /admin/discovery {url}` | admin console | | ||
| 35 | 39 | ||||
modified
docs/ARCHITECTURE.md
+18 −3
@@ -35,13 +35,28 @@ derives events, keeps the history, and redistributes the result through one web | |||||
| 35 | - **L3** — `observations` (partitioned by `received_at` day; fingerprint dedupes reconnect replays; `normalizer_version`; REALTIME streaming ticks are persisted at most once per source/instrument/field per `MA_TICK_PERSIST_INTERVAL_MS` = 2 s while consensus/events see every tick). Retention `MA_OBSERVATION_RETENTION_DAYS` then gzip NDJSON archive in `MA_DATA_DIR/archive` (never silently deleted; `observation_archives` index). | 35 | - **L3** — `observations` (partitioned by `received_at` day; fingerprint dedupes reconnect replays; `normalizer_version`; REALTIME streaming ticks are persisted at most once per source/instrument/field per `MA_TICK_PERSIST_INTERVAL_MS` = 2 s while consensus/events see every tick). Retention `MA_OBSERVATION_RETENTION_DAYS` then gzip NDJSON archive in `MA_DATA_DIR/archive` (never silently deleted; `observation_archives` index). | ||
| 36 | - **L4** — `canonical_quotes`, `bars`, `market_events`, `filings`, `connector_health`, `fact_changes`, `document_snapshots`. | 36 | - **L4** — `canonical_quotes`, `bars`, `market_events`, `filings`, `connector_health`, `fact_changes`, `document_snapshots`. | ||
| 37 | 37 | ||||
| 38 | +## Source Mesh v2 (2026-09-13) | ||||
| 39 | + | ||||
| 40 | +**One instrument → many observers → one canonical market state.** Every observation carries an `observation_type` | ||||
| 41 | +(TRADE, MID, QUOTE, INDEX_VALUE, INDICATIVE, STABLECOIN_PROXY, DERIVED, OFFICIAL_FIX, REFERENCE_RATE, SETTLEMENT, EOD_CLOSE, NAV) that | ||||
| 42 | +maps to a comparability class (LIVE / FIX / EOD). Only comparable observations are compared: an ECB fixing is never measured against a live | ||||
| 43 | +market, last week's close is never "divergent" from yesterday's. Roles: real markets **vote**; stablecoin/derived proxies **confirm** | ||||
| 44 | +(reduced weight, never counted as families); restricted-rights sources (nasdaq.com) **validate** only (`validation_only`, never voting, | ||||
| 45 | +never redistributed). `core/coverage.ts` scores every quoted instrument against a tier target (A ≥ 3 families / 5 observations; B ≥ 2 / 3; | ||||
| 46 | +C 1 / 2; D official single source) and produces the source-expansion queue (`GET /v1/coverage`). `core/lineage.ts` infers shared upstreams | ||||
| 47 | +statistically (identical values within 2 s over ≥ 120 aligned samples → similarity ≥ 0.92 → one family, `source_lineage` table). | ||||
| 48 | +Live FX without keys: Kraken fiat markets (12 pairs, TRADE), Bitstamp EUR/USD & GBP/USD, Bitfinex EUR/GBP/JPY vs USD, plus stablecoin | ||||
| 49 | +proxies (EUR/USDT, USDC/EUR, USDT/BRL…) on Binance, OKX, Coinbase, Bybit, Gate, KuCoin. | ||||
| 50 | + | ||||
| 38 | ## Consensus (apps/api/src/core/consensus.ts) | 51 | ## Consensus (apps/api/src/core/consensus.ts) | ||
| 39 | 52 | ||||
| 40 | Per instrument and field, the newest observation of each source is kept (out-of-order values ignored). A value is *fresh* within a | 53 | Per instrument and field, the newest observation of each source is kept (out-of-order values ignored). A value is *fresh* within a | ||
| 41 | window depending on its real-time class (REALTIME 15 s, DELAYED 30 min, INDICATIVE 1 h, END_OF_DAY 10 d; 4 d for live classes while the | 54 | window depending on its real-time class (REALTIME 15 s, DELAYED 30 min, INDICATIVE 1 h, END_OF_DAY 10 d; 4 d for live classes while the | ||
| 42 | −venue is closed — the last session value is reported as AT_CLOSE, never as live). Weight = source reliability × timestamp trust × real-time class × freshness decay × official bonus. One vote per | 55 | +venue is closed — the last session value is reported as AT_CLOSE, never as live). Comparability class first (LIVE from real markets > FIX > | ||
| 43 | −**source family** (sources believed to share an upstream count once). When live classes exist, END_OF_DAY/INDICATIVE values are | 56 | +EOD > live proxies alone), then temporal tolerance inside the class (live: 2 min; fixings/closes: same UTC day → `temporal_mismatch`). | ||
| 44 | −*superseded*. Outliers (> 2 % from the weighted median with ≥ 3 candidates) are excluded. Output: weighted median, dispersion (bps), | 57 | +Weight = source reliability × timestamp trust × real-time class × freshness decay × official bonus (×1.6 for rates). One vote per | ||
| 58 | +**source family** (declared or statistically inferred). Crypto weights scale with the venue's share of 24 h notional volume. | ||||
| 59 | +Outliers (> 2 % from the weighted median with ≥ 3 candidates) are excluded. Output: weighted median, dispersion (bps), | ||||
| 45 | independent-family count, freshness, confidence (agreement + redundancy + freshness + reliability, capped at 0.995), and the full | 60 | independent-family count, freshness, confidence (agreement + redundancy + freshness + reliability, capped at 0.995), and the full | ||
| 46 | contribution list with inclusion reasons — exposed as **"Why this price?"** (`GET /v1/quotes/:id/provenance`). | 61 | contribution list with inclusion reasons — exposed as **"Why this price?"** (`GET /v1/quotes/:id/provenance`). | ||
| 47 | 62 | ||||
modified
docs/CONNECTORS.md
+7 −3
@@ -43,9 +43,13 @@ Side outputs of `normalize()`: | |||||
| 43 | | `holidays` | market-hours engine (`exchange_holidays`) | | 43 | | `holidays` | market-hours engine (`exchange_holidays`) | | ||
| 44 | | `bars` | historical backfill (`bars`, producer = connector id) | | 44 | | `bars` | historical backfill (`bars`, producer = connector id) | | ||
| 45 | 45 | ||||
| 46 | −Production connectors (v0.1): coinbase-ws, kraken-ws, binance-ws, okx-ws (WEBSOCKET) · cboe-delayed-quotes (XHR) · ecb-frankfurter, | 46 | +Production connectors (v0.2, 22): coinbase-ws, kraken-ws (+12 fiat pairs), binance-ws, okx-ws, bitstamp-ws (+EUR/USD, GBP/USD), gemini-ws, | ||
| 47 | −bank-of-canada-valet, hfmarketdata-daily (OFFICIAL_API) · us-treasury-yield-curve (XML) · sec-edgar-filings, nasdaq-trade-halts (RSS) · | 47 | +bitfinex-ws (+EUR/GBP/JPY vs USD), bybit-ws, gate-ws, cryptocom-ws, kucoin-ws (WEBSOCKET) · cboe-delayed-quotes, nasdaq-quote-api (validator) | ||
| 48 | −sec-company-tickers, nasdaq-symbol-directory (BULK_FILE) · nasdaq-market-calendar (HTML change detection). See each `README.md`. | 48 | +(XHR) · ecb-frankfurter, bank-of-canada-valet, hfmarketdata-daily (OFFICIAL_API) · us-treasury-yield-curve (XML) · sec-edgar-filings, | ||
| 49 | +nasdaq-trade-halts (RSS) · sec-company-tickers, nasdaq-symbol-directory (BULK_FILE) · nasdaq-market-calendar (HTML). All keyless | ||||
| 50 | +(hfmarketdata accepts an optional key of the sister platform). Declare `observationType` on every observation (TRADE / QUOTE / OFFICIAL_FIX / | ||||
| 51 | +REFERENCE_RATE / EOD_CLOSE / STABLECOIN_PROXY…); use `_shared/pairs.ts` (`planPair`, `pairObservations`) for venue pairs so fiat/fiat markets | ||||
| 52 | +become FOREX TRADE observations and stable/fiat markets become STABLECOIN_PROXY observations (inverted when needed). See each `README.md`. | ||||
| 49 | 53 | ||||
| 50 | Onboarding workflow for a *discovered* source: `POST /v1/admin/discovery {url}` → candidate report (endpoints, embedded state, price/symbol-like | 54 | Onboarding workflow for a *discovered* source: `POST /v1/admin/discovery {url}` → candidate report (endpoints, embedded state, price/symbol-like | ||
| 51 | fields) → manual rights review → connector + fixtures → staging (`enabled: false` or `MA_DISABLED_CONNECTORS`) → production. | 55 | fields) → manual rights review → connector + fixtures → staging (`enabled: false` or `MA_DISABLED_CONNECTORS`) → production. | ||
added
infra/migrations/0002_observation_types.sql
+17 −0
@@ -0,0 +1,17 @@ | |||||
| 1 | +-- Observation types (comparability classes) + canonical quote coverage columns. | ||||
| 2 | +alter table observations add column if not exists observation_type text; | ||||
| 3 | +alter table canonical_quotes add column if not exists observation_count integer not null default 0; | ||||
| 4 | +alter table canonical_quotes add column if not exists proxy_count integer not null default 0; | ||||
| 5 | +alter table canonical_quotes add column if not exists validator_count integer not null default 0; | ||||
| 6 | +alter table canonical_quotes add column if not exists comparability text; | ||||
| 7 | + | ||||
| 8 | +-- Inferred source lineage (statistical shared-upstream detection). | ||||
| 9 | +create table if not exists source_lineage ( | ||||
| 10 | + source_a text not null, | ||||
| 11 | + source_b text not null, | ||||
| 12 | + similarity double precision not null, | ||||
| 13 | + samples integer not null, | ||||
| 14 | + instruments integer not null, | ||||
| 15 | + computed_at timestamptz not null default now(), | ||||
| 16 | + primary key (source_a, source_b) | ||||
| 17 | +); | ||||
modified
packages/market-model/src/enums.ts
+38 −0
@@ -93,6 +93,44 @@ export const FIELDS = [ | |||||
| 93 | ] as const; | 93 | ] as const; | ||
| 94 | export type Field = (typeof FIELDS)[number]; | 94 | export type Field = (typeof FIELDS)[number]; | ||
| 95 | 95 | ||||
| 96 | +/** | ||||
| 97 | + * What an observation *is*. Values of different comparability classes are never compared to each | ||||
| 98 | + * other (an ECB fixing is not a live mid, an exchange settlement is not a trade). | ||||
| 99 | + */ | ||||
| 100 | +export const OBSERVATION_TYPES = [ | ||||
| 101 | + "TRADE", // last trade on a venue | ||||
| 102 | + "MID", // mid of a quoted market | ||||
| 103 | + "QUOTE", // bid/ask style value | ||||
| 104 | + "INDEX_VALUE", // computed index level | ||||
| 105 | + "INDICATIVE", // indicative live value (non-tradable) | ||||
| 106 | + "STABLECOIN_PROXY", // fiat rate implied from a stablecoin market (USDT/USDC ≈ USD) | ||||
| 107 | + "DERIVED", // computed by Market Atlas (cross rates…) | ||||
| 108 | + "OFFICIAL_FIX", // central-bank / official fixing | ||||
| 109 | + "REFERENCE_RATE", // official reference or policy rate | ||||
| 110 | + "SETTLEMENT", // exchange settlement price | ||||
| 111 | + "EOD_CLOSE", // end-of-day close from a historical dataset | ||||
| 112 | + "NAV", | ||||
| 113 | +] as const; | ||||
| 114 | +export type ObservationType = (typeof OBSERVATION_TYPES)[number]; | ||||
| 115 | + | ||||
| 116 | +export type ComparabilityClass = "LIVE" | "FIX" | "EOD"; | ||||
| 117 | +export const COMPARABILITY: Record<ObservationType, ComparabilityClass> = { | ||||
| 118 | + TRADE: "LIVE", | ||||
| 119 | + MID: "LIVE", | ||||
| 120 | + QUOTE: "LIVE", | ||||
| 121 | + INDEX_VALUE: "LIVE", | ||||
| 122 | + INDICATIVE: "LIVE", | ||||
| 123 | + STABLECOIN_PROXY: "LIVE", | ||||
| 124 | + DERIVED: "LIVE", | ||||
| 125 | + OFFICIAL_FIX: "FIX", | ||||
| 126 | + REFERENCE_RATE: "FIX", | ||||
| 127 | + SETTLEMENT: "EOD", | ||||
| 128 | + EOD_CLOSE: "EOD", | ||||
| 129 | + NAV: "EOD", | ||||
| 130 | +}; | ||||
| 131 | +/** Observation types that are proxies/derivations: they confirm but never count as independent families. */ | ||||
| 132 | +export const PROXY_TYPES: ReadonlySet<ObservationType> = new Set<ObservationType>(["STABLECOIN_PROXY", "DERIVED", "INDICATIVE"]); | ||||
| 133 | + | ||||
| 96 | export const TIMESTAMP_TRUSTS = ["EXCHANGE", "SOURCE", "CONNECTOR", "UNKNOWN"] as const; | 134 | export const TIMESTAMP_TRUSTS = ["EXCHANGE", "SOURCE", "CONNECTOR", "UNKNOWN"] as const; | ||
| 97 | export type TimestampTrust = (typeof TIMESTAMP_TRUSTS)[number]; | 135 | export type TimestampTrust = (typeof TIMESTAMP_TRUSTS)[number]; | ||
| 98 | 136 | ||||
modified
packages/market-model/src/schemas.ts
+2 −0
@@ -3,6 +3,7 @@ import { | |||||
| 3 | ASSET_CLASSES, | 3 | ASSET_CLASSES, | ||
| 4 | EVENT_TYPES, | 4 | EVENT_TYPES, | ||
| 5 | FIELDS, | 5 | FIELDS, | ||
| 6 | + OBSERVATION_TYPES, | ||||
| 6 | REALTIME_STATUSES, | 7 | REALTIME_STATUSES, | ||
| 7 | RIGHTS_STATUSES, | 8 | RIGHTS_STATUSES, | ||
| 8 | SEVERITIES, | 9 | SEVERITIES, | ||
@@ -32,6 +33,7 @@ export const NormalizedObservationSchema = z.object({ | |||||
| 32 | field: z.enum(FIELDS), | 33 | field: z.enum(FIELDS), | ||
| 33 | value: z.number().finite(), | 34 | value: z.number().finite(), | ||
| 34 | currency: z.string().nullable().optional(), | 35 | currency: z.string().nullable().optional(), | ||
| 36 | + observationType: z.enum(OBSERVATION_TYPES).optional(), | ||||
| 35 | sourceTimestamp: z.number().int().nullable(), | 37 | sourceTimestamp: z.number().int().nullable(), | ||
| 36 | timestampTrust: z.enum(TIMESTAMP_TRUSTS), | 38 | timestampTrust: z.enum(TIMESTAMP_TRUSTS), | ||
| 37 | sequence: z.union([z.number(), z.string()]).nullable().optional(), | 39 | sequence: z.union([z.number(), z.string()]).nullable().optional(), | ||
modified
packages/market-model/src/types.ts
+16 −2
@@ -5,6 +5,7 @@ import type { | |||||
| 5 | EventType, | 5 | EventType, | ||
| 6 | Field, | 6 | Field, | ||
| 7 | MarketState, | 7 | MarketState, | ||
| 8 | + ObservationType, | ||||
| 8 | RealtimeStatus, | 9 | RealtimeStatus, | ||
| 9 | RightsStatus, | 10 | RightsStatus, | ||
| 10 | Severity, | 11 | Severity, | ||
@@ -160,6 +161,8 @@ export interface NormalizedObservation { | |||||
| 160 | field: Field; | 161 | field: Field; | ||
| 161 | value: number; | 162 | value: number; | ||
| 162 | currency?: string | null; | 163 | currency?: string | null; | ||
| 164 | + /** Nature of the value (TRADE, OFFICIAL_FIX, EOD_CLOSE…). Inferred from realtimeStatus when omitted. */ | ||||
| 165 | + observationType?: ObservationType; | ||||
| 163 | /** Source event time (ms epoch) — null when the source does not provide one. */ | 166 | /** Source event time (ms epoch) — null when the source does not provide one. */ | ||
| 164 | sourceTimestamp: number | null; | 167 | sourceTimestamp: number | null; | ||
| 165 | timestampTrust: TimestampTrust; | 168 | timestampTrust: TimestampTrust; | ||
@@ -208,7 +211,10 @@ export interface SourceContribution { | |||||
| 208 | ageMs: number; | 211 | ageMs: number; | ||
| 209 | weight: number; | 212 | weight: number; | ||
| 210 | included: boolean; | 213 | included: boolean; | ||
| 211 | − reason?: string; // why excluded (stale, outlier…) | 214 | + reason?: string; // why excluded (stale, outlier, not_comparable, temporal_mismatch, validation_only…) | ||
| 215 | + observationType: ObservationType; | ||||
| 216 | + /** Difference to the canonical value in basis points (null when no canonical value). */ | ||||
| 217 | + deltaBps?: number | null; | ||||
| 212 | realtimeStatus: RealtimeStatus; | 218 | realtimeStatus: RealtimeStatus; | ||
| 213 | rightsStatus: RightsStatus; | 219 | rightsStatus: RightsStatus; | ||
| 214 | } | 220 | } | ||
@@ -227,8 +233,16 @@ export interface CanonicalQuote { | |||||
| 227 | bid: number | null; | 233 | bid: number | null; | ||
| 228 | ask: number | null; | 234 | ask: number | null; | ||
| 229 | currency: string | null; | 235 | currency: string | null; | ||
| 230 | − /** Number of independent source families supporting `price`. */ | 236 | + /** Number of independent source families supporting `price` (proxies excluded). */ | ||
| 231 | sourceCount: number; | 237 | sourceCount: number; | ||
| 238 | + /** Fresh observations of any kind seen for the price field (included or not). */ | ||||
| 239 | + observationCount: number; | ||||
| 240 | + /** Proxy/derived confirmations (stablecoin markets, crosses) that agree with the canonical value. */ | ||||
| 241 | + proxyCount: number; | ||||
| 242 | + /** Restricted-rights sources used for validation only that agree with the canonical value. */ | ||||
| 243 | + validatorCount: number; | ||||
| 244 | + /** Comparability class of the canonical value. */ | ||||
| 245 | + comparability: "LIVE" | "FIX" | "EOD" | null; | ||||
| 232 | dispersionBps: number | null; | 246 | dispersionBps: number | null; | ||
| 233 | confidence: number; | 247 | confidence: number; | ||
| 234 | /** Age of the newest included observation at computation time. */ | 248 | /** Age of the newest included observation at computation time. */ | ||
| 235 | 249 | ||||