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