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"; import { conventionalPair, fxHint } from "../ecb-frankfurter/index.js"; const BASE = "https://www.bankofcanada.ca/valet/observations"; const RATE_SERIES: Record = { V39079: { symbol: "CA_POLICY_RATE", name: "Bank of Canada target for the overnight rate", assetClass: "INTEREST_RATE", field: "RATE" }, "BD.CDN.2YR.DQ.YLD": { symbol: "CA_2Y", name: "Government of Canada 2-year benchmark bond yield", assetClass: "BOND", field: "YIELD" }, "BD.CDN.5YR.DQ.YLD": { symbol: "CA_5Y", name: "Government of Canada 5-year benchmark bond yield", assetClass: "BOND", field: "YIELD" }, "BD.CDN.10YR.DQ.YLD": { symbol: "CA_10Y", name: "Government of Canada 10-year benchmark bond yield", assetClass: "BOND", field: "YIELD" }, "BD.CDN.LONG.DQ.YLD": { symbol: "CA_30Y", name: "Government of Canada long-term benchmark bond yield", assetClass: "BOND", field: "YIELD" }, }; const 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" } }); const FX_CCYS = ["USD", "EUR", "GBP", "JPY", "CHF", "AUD", "CNY", "MXN", "INR", "BRL", "HKD", "KRW", "SEK", "NOK", "SGD", "NZD", "ZAR", "TRY"]; const seeds: ProposedInstrument[] = [ ...Object.values(RATE_SERIES).map((s) => ({ symbol: s.symbol, hint: rateHint(s) })), ...FX_CCYS.map((c) => { const [b, q] = conventionalPair(c, "CAD"); return { symbol: `${b}${q}`, hint: fxHint(b, q) }; }), ]; /** Bank of Canada Valet API: daily FX averages (CAD per unit) + policy rate + benchmark bond yields. Independent official source for USD/CAD. */ export const bankOfCanada = defineConnector({ metadata: { id: "bank-of-canada-valet", name: "Bank of Canada — Valet (FX, policy rate, bond yields)", version: "1.0.0", sourceId: "bank-of-canada", organization: "Bank of Canada", sourceType: "OFFICIAL_API", jurisdiction: "CA", rightsStatus: "OFFICIAL_OPEN_DATA", realtimeStatus: "END_OF_DAY", expectedLatencyMs: null, supportsStreaming: false, supportsHistorical: true, assetClasses: ["FOREX", "INTEREST_RATE", "BOND"], exchanges: [], homepage: "https://www.bankofcanada.ca/valet/docs", 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).", rightsNotes: "Bank of Canada content is available under its terms of use with attribution; exchange rates are indicative daily averages.", termsUrl: "https://www.bankofcanada.ca/terms/", sourceFamily: "bank-of-canada", enabled: true, }, seeds, defaultSymbols: seeds.map((s) => s.symbol), rateLimits: { "www.bankofcanada.ca": 1 }, schedule: { intervalMs: 2 * 60 * 60_000 }, async poll(ctx) { const out = []; const fx = await ctx.http.getJson(`${BASE}/group/FX_RATES_DAILY/json?recent=3`); if (!fx.response.notModified) out.push(raw("bank-of-canada-valet", "bank-of-canada", "fx", fx.data)); const rates = await ctx.http.getJson(`${BASE}/${Object.keys(RATE_SERIES).join(",")}/json?recent=3`); if (!rates.response.notModified) out.push(raw("bank-of-canada-valet", "bank-of-canada", "rates", rates.data)); return out; }, normalize(r): NormalizedBatch { const p = r.payload as { observations?: Array>; seriesDetail?: Record }; if (!Array.isArray(p.observations)) return { observations: [] }; const rows = [...p.observations].sort((a, b) => String(a.d).localeCompare(String(b.d))); const observations: NormalizedObservation[] = []; // Latest value per series (+ previous for PREVIOUS_CLOSE) const seriesIds = new Set(rows.flatMap((row) => Object.keys(row).filter((k) => k !== "d"))); for (const sid of seriesIds) { const withValue = rows.filter((row) => typeof (row[sid] as { v?: string } | undefined)?.v === "string"); const last = withValue[withValue.length - 1]; const prev = withValue[withValue.length - 2]; if (!last) continue; // Discontinued series (e.g. currencies the Bank stopped publishing) keep an old last value: skip anything > 10 days old. if (r.receivedAt - Date.parse(`${String(last.d)}T00:00:00Z`) > 10 * 86_400_000) continue; const emit = (row: Record, field: "LAST_PRICE" | "PREVIOUS_CLOSE" | "RATE" | "YIELD") => { const value = Number((row[sid] as { v?: string }).v); if (!Number.isFinite(value)) return; const date = String(row.d); const ts = zonedTimeToUtc(`${date}T16:30:00`, "America/Toronto"); if (r.kind === "fx" && /^FX[A-Z]{3}CAD$/.test(sid)) { const foreign = sid.slice(2, 5); const [b, q] = conventionalPair(foreign, "CAD"); const v = b === foreign ? value : 1 / value; // series is CAD per 1 unit of foreign 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 } }); } else if (r.kind === "rates" && RATE_SERIES[sid]) { const s = RATE_SERIES[sid]!; 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 } }); 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 } }); } }; emit(last, "LAST_PRICE"); if (prev) emit(prev, "PREVIOUS_CLOSE"); } return { observations }; }, fixturesDir: "fixtures", });