SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
5.7 KB · 97 lines typescript
Raw Blame History
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