SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
3.0 KB · 69 lines typescript
Raw Blame History
1import { desc, eq, sql } from 'drizzle-orm';2import { fxRates } from '@rareindex/database';3import { SUPPORTED_CURRENCIES, logger, toDateOnly } from '@rareindex/shared';4import { db } from './lib/db.ts';56/**7 * FX sync from the ECB reference rates published through frankfurter (https://api.frankfurter.app,8 * an open-data mirror of the ECB euro reference rates). Stored as base USD → quote per 1 USD.9 * Non-ECB currencies (e.g. TWD, AED) stay absent; sales in those currencies keep price_usd NULL10 * until a rate source exists — never approximated.11 */12const API = 'https://api.frankfurter.app';13const log = logger.child({ component: 'fx' });1415interface RangeResponse {16  base: string;17  start_date: string;18  end_date: string;19  rates: Record<string, Record<string, number>>;20}2122async function availableCurrencies(): Promise<string[]> {23  const res = await fetch(`${API}/currencies`, { headers: { 'user-agent': 'RareIndexBot/0.1 (fx sync)' } });24  if (!res.ok) throw new Error(`frankfurter currencies HTTP ${res.status}`);25  const json = (await res.json()) as Record<string, string>;26  return Object.keys(json);27}2829export async function syncFxRange(start: string, end: string): Promise<number> {30  const avail = new Set(await availableCurrencies());31  const quotes = SUPPORTED_CURRENCIES.filter((c) => c !== 'USD' && avail.has(c));32  const res = await fetch(`${API}/${start}..${end}?from=USD&to=${quotes.join(',')}`, { headers: { 'user-agent': 'RareIndexBot/0.1 (fx sync)' } });33  if (!res.ok) throw new Error(`frankfurter range HTTP ${res.status}`);34  const json = (await res.json()) as RangeResponse;35  const rows: Array<typeof fxRates.$inferInsert> = [];36  for (const [date, byQuote] of Object.entries(json.rates ?? {})) {37    for (const [quote, rate] of Object.entries(byQuote)) rows.push({ date, base: 'USD', quote, rate, source: 'ecb' });38  }39  for (let i = 0; i < rows.length; i += 1000) {40    await db()41      .insert(fxRates)42      .values(rows.slice(i, i + 1000))43      .onConflictDoUpdate({ target: [fxRates.date, fxRates.base, fxRates.quote], set: { rate: sql`excluded.rate` } });44  }45  return rows.length;46}4748/** Daily sync: from the last stored date (or 30 days back) to today. `backfill` loads 1999→today in yearly chunks. */49export async function syncFx(opts: { backfill?: boolean } = {}): Promise<{ rows: number; from: string; to: string }> {50  const today = toDateOnly(new Date());51  let from: string;52  if (opts.backfill) from = '1999-01-04';53  else {54    const [last] = await db().select({ date: fxRates.date }).from(fxRates).where(eq(fxRates.base, 'USD')).orderBy(desc(fxRates.date)).limit(1);55    from = last ? last.date : toDateOnly(new Date(Date.now() - 30 * 86_400_000));56  }57  let total = 0;58  let cursor = from;59  while (cursor <= today) {60    const year = Number(cursor.slice(0, 4));61    const end = `${year}-12-31` < today ? `${year}-12-31` : today;62    const n = await syncFxRange(cursor, end);63    total += n;64    log.info({ from: cursor, to: end, rows: n }, 'fx range synced');65    cursor = `${year + 1}-01-01`;66  }67  return { rows: total, from, to: today };68}69