spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1import { defineConnector, raw, type NormalizedBatch, type ProposedInstrument } from "@market-atlas/connector-sdk";2import type { InstrumentHint, NormalizedObservation } from "@market-atlas/market-model";3import { zonedTimeToUtc } from "@market-atlas/market-model";45const BASE = "https://api.frankfurter.dev/v1";6const BASES = ["EUR", "USD"];7const CURRENCY_NAMES: Record<string, string> = { EUR: "Euro", USD: "US Dollar", GBP: "British Pound", JPY: "Japanese Yen", CHF: "Swiss Franc", CAD: "Canadian Dollar", AUD: "Australian Dollar", NZD: "New Zealand Dollar", CNY: "Chinese Yuan", HKD: "Hong Kong Dollar", SGD: "Singapore Dollar", SEK: "Swedish Krona", NOK: "Norwegian Krone", DKK: "Danish Krone", PLN: "Polish Złoty", CZK: "Czech Koruna", HUF: "Hungarian Forint", MXN: "Mexican Peso", BRL: "Brazilian Real", INR: "Indian Rupee", KRW: "South Korean Won", ZAR: "South African Rand", TRY: "Turkish Lira", ILS: "Israeli Shekel", IDR: "Indonesian Rupiah", MYR: "Malaysian Ringgit", PHP: "Philippine Peso", THB: "Thai Baht", ISK: "Icelandic Króna", RON: "Romanian Leu", BGN: "Bulgarian Lev" };8const FEATURED = new Set(["EURUSD", "USDJPY", "GBPUSD", "USDCHF", "USDCAD", "AUDUSD", "USDCNY", "EURGBP"]);910export function fxHint(base: string, quote: string): InstrumentHint {11 return { assetClass: "FOREX", name: `${CURRENCY_NAMES[base] ?? base} / ${CURRENCY_NAMES[quote] ?? quote}`, exchangeId: null, currency: quote, country: "XX", base, quote, securityType: "SPOT", metadata: { featured: FEATURED.has(`${base}${quote}`) } };12}1314/** Conventional market quoting: EUR/GBP/AUD/NZD are quoted as base against USD; USD is base against the rest. */15export function conventionalPair(a: string, b: string): [string, string] {16 const priority = ["EUR", "GBP", "AUD", "NZD", "USD"];17 const ia = priority.indexOf(a);18 const ib = priority.indexOf(b);19 if (ia === -1 && ib === -1) return [a, b];20 if (ia === -1) return [b, a];21 if (ib === -1) return [a, b];22 return ia < ib ? [a, b] : [b, a];23}2425const seeds: ProposedInstrument[] = Object.keys(CURRENCY_NAMES)26 .flatMap((c) => (c === "USD" ? [] : [conventionalPair("USD", c)]))27 .concat(Object.keys(CURRENCY_NAMES).flatMap((c) => (c === "EUR" || c === "USD" ? [] : [conventionalPair("EUR", c)])))28 .map(([b, q]) => ({ symbol: `${b}${q}`, hint: fxHint(b, q), aliases: [`${b}/${q}`, `${b}-${q}`] }));2930/**31 * ECB euro foreign exchange reference rates via the Frankfurter open API (a 1:1 redistribution of the32 * ECB daily fixing, ~16:00 CET). Two requests per poll (EUR and USD base, 8-day window for previous close).33 */34export const ecbFrankfurter = defineConnector({35 metadata: {36 id: "ecb-frankfurter",37 name: "ECB reference rates (Frankfurter)",38 version: "1.0.0",39 sourceId: "ecb-frankfurter",40 organization: "European Central Bank",41 sourceType: "OFFICIAL_API",42 jurisdiction: "EU",43 rightsStatus: "OFFICIAL_OPEN_DATA",44 realtimeStatus: "END_OF_DAY",45 expectedLatencyMs: null,46 supportsStreaming: false,47 supportsHistorical: true,48 assetClasses: ["FOREX"],49 exchanges: [],50 homepage: "https://frankfurter.dev",51 description: "Daily ECB euro reference rates for 30 currencies, cross-computed to USD pairs. One fixing per business day (~16:00 CET); values are indicative reference rates, not tradable quotes.",52 rightsNotes: "ECB reference rates are free to reuse with attribution to the ECB. Frankfurter is an open-source (MIT) redistribution.",53 termsUrl: "https://www.ecb.europa.eu/services/disclaimer/html/index.en.html",54 sourceFamily: "ecb",55 enabled: true,56 },57 seeds,58 defaultSymbols: seeds.map((s) => s.symbol),59 rateLimits: { "api.frankfurter.dev": 1 },60 schedule: { intervalMs: 60 * 60_000 },61 async poll(ctx) {62 const end = new Date();63 const start = new Date(end.getTime() - 8 * 86_400_000).toISOString().slice(0, 10);64 const out = [];65 for (const base of BASES) {66 const { data, response } = await ctx.http.getJson(`${BASE}/${start}..?base=${base}`);67 if (response.notModified) continue;68 out.push(raw("ecb-frankfurter", "ecb-frankfurter", "timeseries", data, { base }));69 }70 return out;71 },72 normalize(r): NormalizedBatch {73 const p = r.payload as { base?: string; rates?: Record<string, Record<string, number>> };74 if (r.kind !== "timeseries" || !p.base || !p.rates) return { observations: [] };75 const dates = Object.keys(p.rates).sort();76 const latest = dates[dates.length - 1];77 const previous = dates[dates.length - 2];78 if (!latest) return { observations: [] };79 const observations: NormalizedObservation[] = [];80 const emit = (date: string, field: "LAST_PRICE" | "PREVIOUS_CLOSE", refDate: string) => {81 const ts = zonedTimeToUtc(`${date}T16:00:00`, "Europe/Berlin");82 for (const [ccy, rate] of Object.entries(p.rates![date] ?? {})) {83 if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) continue;84 // Only emit the conventional orientation; avoid duplicating EUR-quoted pairs from the USD base call.85 const [b, q] = conventionalPair(p.base!, ccy);86 if (p.base === "USD" && (b === "EUR" || q === "EUR")) continue;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, 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 }90 };91 emit(latest, "LAST_PRICE", latest);92 if (previous) emit(previous, "PREVIOUS_CLOSE", latest);93 return { observations, stats: { dates: dates.length } };94 },95 fixturesDir: "fixtures",96});97