import { defineConnector, raw, type NormalizedBatch, type ProposedInstrument } from "@market-atlas/connector-sdk"; import type { InstrumentHint, NormalizedObservation } from "@market-atlas/market-model"; import { zonedTimeToUtc } from "@market-atlas/market-model"; const BASE = "https://api.frankfurter.dev/v1"; const BASES = ["EUR", "USD"]; const CURRENCY_NAMES: Record = { 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" }; const FEATURED = new Set(["EURUSD", "USDJPY", "GBPUSD", "USDCHF", "USDCAD", "AUDUSD", "USDCNY", "EURGBP"]); export function fxHint(base: string, quote: string): InstrumentHint { 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}`) } }; } /** Conventional market quoting: EUR/GBP/AUD/NZD are quoted as base against USD; USD is base against the rest. */ export function conventionalPair(a: string, b: string): [string, string] { const priority = ["EUR", "GBP", "AUD", "NZD", "USD"]; const ia = priority.indexOf(a); const ib = priority.indexOf(b); if (ia === -1 && ib === -1) return [a, b]; if (ia === -1) return [b, a]; if (ib === -1) return [a, b]; return ia < ib ? [a, b] : [b, a]; } const seeds: ProposedInstrument[] = Object.keys(CURRENCY_NAMES) .flatMap((c) => (c === "USD" ? [] : [conventionalPair("USD", c)])) .concat(Object.keys(CURRENCY_NAMES).flatMap((c) => (c === "EUR" || c === "USD" ? [] : [conventionalPair("EUR", c)]))) .map(([b, q]) => ({ symbol: `${b}${q}`, hint: fxHint(b, q), aliases: [`${b}/${q}`, `${b}-${q}`] })); /** * ECB euro foreign exchange reference rates via the Frankfurter open API (a 1:1 redistribution of the * ECB daily fixing, ~16:00 CET). Two requests per poll (EUR and USD base, 8-day window for previous close). */ export const ecbFrankfurter = defineConnector({ metadata: { id: "ecb-frankfurter", name: "ECB reference rates (Frankfurter)", version: "1.0.0", sourceId: "ecb-frankfurter", organization: "European Central Bank", sourceType: "OFFICIAL_API", jurisdiction: "EU", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", expectedLatencyMs: null, supportsStreaming: false, supportsHistorical: true, assetClasses: ["FOREX"], exchanges: [], homepage: "https://frankfurter.dev", 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.", rightsNotes: "ECB reference rates are free to reuse with attribution to the ECB. Frankfurter is an open-source (MIT) redistribution.", termsUrl: "https://www.ecb.europa.eu/services/disclaimer/html/index.en.html", sourceFamily: "ecb", enabled: true, }, seeds, defaultSymbols: seeds.map((s) => s.symbol), rateLimits: { "api.frankfurter.dev": 1 }, schedule: { intervalMs: 60 * 60_000 }, async poll(ctx) { const end = new Date(); const start = new Date(end.getTime() - 8 * 86_400_000).toISOString().slice(0, 10); const out = []; for (const base of BASES) { const { data, response } = await ctx.http.getJson(`${BASE}/${start}..?base=${base}`); if (response.notModified) continue; out.push(raw("ecb-frankfurter", "ecb-frankfurter", "timeseries", data, { base })); } return out; }, normalize(r): NormalizedBatch { const p = r.payload as { base?: string; rates?: Record> }; if (r.kind !== "timeseries" || !p.base || !p.rates) return { observations: [] }; const dates = Object.keys(p.rates).sort(); const latest = dates[dates.length - 1]; const previous = dates[dates.length - 2]; if (!latest) return { observations: [] }; const observations: NormalizedObservation[] = []; const emit = (date: string, field: "LAST_PRICE" | "PREVIOUS_CLOSE", refDate: string) => { const ts = zonedTimeToUtc(`${date}T16:00:00`, "Europe/Berlin"); for (const [ccy, rate] of Object.entries(p.rates![date] ?? {})) { if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) continue; // Only emit the conventional orientation; avoid duplicating EUR-quoted pairs from the USD base call. const [b, q] = conventionalPair(p.base!, ccy); if (p.base === "USD" && (b === "EUR" || q === "EUR")) continue; const value = b === p.base ? rate : 1 / rate; 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 } }); } }; emit(latest, "LAST_PRICE", latest); if (previous) emit(previous, "PREVIOUS_CLOSE", latest); return { observations, stats: { dates: dates.length } }; }, fixturesDir: "fixtures", });