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%
6.6 KB · 98 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";4import { conventionalPair, fxHint } from "../ecb-frankfurter/index.js";56const BASE = "https://www.bankofcanada.ca/valet/observations";7const RATE_SERIES: Record<string, { symbol: string; name: string; assetClass: "INTEREST_RATE" | "BOND"; field: "RATE" | "YIELD" }> = {8  V39079: { symbol: "CA_POLICY_RATE", name: "Bank of Canada target for the overnight rate", assetClass: "INTEREST_RATE", field: "RATE" },9  "BD.CDN.2YR.DQ.YLD": { symbol: "CA_2Y", name: "Government of Canada 2-year benchmark bond yield", assetClass: "BOND", field: "YIELD" },10  "BD.CDN.5YR.DQ.YLD": { symbol: "CA_5Y", name: "Government of Canada 5-year benchmark bond yield", assetClass: "BOND", field: "YIELD" },11  "BD.CDN.10YR.DQ.YLD": { symbol: "CA_10Y", name: "Government of Canada 10-year benchmark bond yield", assetClass: "BOND", field: "YIELD" },12  "BD.CDN.LONG.DQ.YLD": { symbol: "CA_30Y", name: "Government of Canada long-term benchmark bond yield", assetClass: "BOND", field: "YIELD" },13};14const rateHint = (s: (typeof RATE_SERIES)[string]): InstrumentHint => ({ assetClass: s.assetClass, name: s.name, exchangeId: null, currency: "CAD", country: "CA", securityType: s.assetClass === "BOND" ? "GOVERNMENT_BOND_YIELD" : "POLICY_RATE", metadata: { featured: true, unit: "percent" } });1516const FX_CCYS = ["USD", "EUR", "GBP", "JPY", "CHF", "AUD", "CNY", "MXN", "INR", "BRL", "HKD", "KRW", "SEK", "NOK", "SGD", "NZD", "ZAR", "TRY"];17const seeds: ProposedInstrument[] = [18  ...Object.values(RATE_SERIES).map((s) => ({ symbol: s.symbol, hint: rateHint(s) })),19  ...FX_CCYS.map((c) => {20    const [b, q] = conventionalPair(c, "CAD");21    return { symbol: `${b}${q}`, hint: fxHint(b, q) };22  }),23];2425/** Bank of Canada Valet API: daily FX averages (CAD per unit) + policy rate + benchmark bond yields. Independent official source for USD/CAD. */26export const bankOfCanada = defineConnector({27  metadata: {28    id: "bank-of-canada-valet",29    name: "Bank of Canada — Valet (FX, policy rate, bond yields)",30    version: "1.0.0",31    sourceId: "bank-of-canada",32    organization: "Bank of Canada",33    sourceType: "OFFICIAL_API",34    jurisdiction: "CA",35    rightsStatus: "OFFICIAL_OPEN_DATA",36    realtimeStatus: "END_OF_DAY",37    expectedLatencyMs: null,38    supportsStreaming: false,39    supportsHistorical: true,40    assetClasses: ["FOREX", "INTEREST_RATE", "BOND"],41    exchanges: [],42    homepage: "https://www.bankofcanada.ca/valet/docs",43    description: "Bank of Canada Valet open API: daily average exchange rates (published ~16:30 ET), the target for the overnight rate and Government of Canada benchmark bond yields (2y, 5y, 10y, long).",44    rightsNotes: "Bank of Canada content is available under its terms of use with attribution; exchange rates are indicative daily averages.",45    termsUrl: "https://www.bankofcanada.ca/terms/",46    sourceFamily: "bank-of-canada",47    enabled: true,48  },49  seeds,50  defaultSymbols: seeds.map((s) => s.symbol),51  rateLimits: { "www.bankofcanada.ca": 1 },52  schedule: { intervalMs: 2 * 60 * 60_000 },53  async poll(ctx) {54    const out = [];55    const fx = await ctx.http.getJson(`${BASE}/group/FX_RATES_DAILY/json?recent=3`);56    if (!fx.response.notModified) out.push(raw("bank-of-canada-valet", "bank-of-canada", "fx", fx.data));57    const rates = await ctx.http.getJson(`${BASE}/${Object.keys(RATE_SERIES).join(",")}/json?recent=3`);58    if (!rates.response.notModified) out.push(raw("bank-of-canada-valet", "bank-of-canada", "rates", rates.data));59    return out;60  },61  normalize(r): NormalizedBatch {62    const p = r.payload as { observations?: Array<Record<string, { v?: string } | string>>; seriesDetail?: Record<string, { label?: string }> };63    if (!Array.isArray(p.observations)) return { observations: [] };64    const rows = [...p.observations].sort((a, b) => String(a.d).localeCompare(String(b.d)));65    const observations: NormalizedObservation[] = [];66    // Latest value per series (+ previous for PREVIOUS_CLOSE)67    const seriesIds = new Set(rows.flatMap((row) => Object.keys(row).filter((k) => k !== "d")));68    for (const sid of seriesIds) {69      const withValue = rows.filter((row) => typeof (row[sid] as { v?: string } | undefined)?.v === "string");70      const last = withValue[withValue.length - 1];71      const prev = withValue[withValue.length - 2];72      if (!last) continue;73      // Discontinued series (e.g. currencies the Bank stopped publishing) keep an old last value: skip anything > 10 days old.74      if (r.receivedAt - Date.parse(`${String(last.d)}T00:00:00Z`) > 10 * 86_400_000) continue;75      const emit = (row: Record<string, { v?: string } | string>, field: "LAST_PRICE" | "PREVIOUS_CLOSE" | "RATE" | "YIELD") => {76        const value = Number((row[sid] as { v?: string }).v);77        if (!Number.isFinite(value)) return;78        const date = String(row.d);79        const ts = zonedTimeToUtc(`${date}T16:30:00`, "America/Toronto");80        if (r.kind === "fx" && /^FX[A-Z]{3}CAD$/.test(sid)) {81          const foreign = sid.slice(2, 5);82          const [b, q] = conventionalPair(foreign, "CAD");83          const v = b === foreign ? value : 1 / value; // series is CAD per 1 unit of foreign84          observations.push({ symbol: `${b}${q}`, instrumentHint: fxHint(b, q), field: field === "RATE" || field === "YIELD" ? "LAST_PRICE" : field, value: Number(v.toPrecision(8)), currency: q, observationType: "OFFICIAL_FIX", sourceTimestamp: ts, timestampTrust: "SOURCE", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", meta: { series: sid, date } });85        } else if (r.kind === "rates" && RATE_SERIES[sid]) {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", 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        }90      };91      emit(last, "LAST_PRICE");92      if (prev) emit(prev, "PREVIOUS_CLOSE");93    }94    return { observations };95  },96  fixturesDir: "fixtures",97});98