BID · Console
Baseline · Intelligence · Decision
src/tools/retrieval/connectors/fred.ts 8,124 bytes · typescript
/**
 * FRED (Federal Reserve Economic Data) connector.
 *
 * Returns time-series observations for an economic indicator from the
 * St. Louis Fed's FRED API. Free, but requires a free API key.
 * Backed by https://api.stlouisfed.org/fred/series/observations.
 *
 * Configuration:
 *   - Reads `FRED_API_KEY` from the process environment by default.
 *     Supplying `FredConfig.apiKey` to the constructor overrides.
 *   - isAvailable() returns false if no key is present so the
 *     dispatcher reports `unavailable` instead of leaking unauth'd
 *     requests upstream.
 *
 * The series id lives on params.entity.id (e.g. "GDPC1" for real
 * GDP, "DGS10" for 10-year Treasury yield). Optional observation
 * window via params.query.observationStart / observationEnd
 * (YYYY-MM-DD).
 */

import {
  RetrievalError,
  type FetchParams,
  type RawPayload,
  type RetrievalConnector,
} from '../interface.js';
import { httpGet, HttpError } from '../http-client.js';
import { RateLimiter } from '../rate-limiter.js';

const USER_AGENT = 'MR mitchell.roy@sia-partners.com';
const FRED_BASE = 'https://api.stlouisfed.org/fred';
const RESPONSE_MAX_BYTES = 32 * 1024 * 1024;

/** FRED documents 120 req/min as a courtesy rate. 2 req/s with burst
 *  of 5 stays comfortably inside that. */
const limiter = new RateLimiter({ requestsPerSecond: 2, burstSize: 5 });

export interface FredConfig {
  readonly apiKey?: string;
}

export interface FredObservation {
  readonly date: string;
  readonly value: number | null;
  readonly realtimeStart: string;
  readonly realtimeEnd: string;
}

export interface FredSeriesResult {
  readonly seriesId: string;
  readonly realtimeStart: string;
  readonly realtimeEnd: string;
  readonly observationStart?: string;
  readonly observationEnd?: string;
  readonly units: string;
  readonly outputType: number;
  readonly fileType: string;
  readonly orderBy: string;
  readonly sortOrder: string;
  readonly count: number;
  readonly observations: readonly FredObservation[];
  readonly sourceUrl: string;
  readonly capturedAt: string;
}

export class FredConnector implements RetrievalConnector {
  readonly name = 'fred';
  readonly authRequired = true;
  readonly rateLimit = { requestsPerSecond: 2, burstSize: 5 };

  private readonly apiKey: string | undefined;

  constructor(cfg: FredConfig = {}) {
    this.apiKey = cfg.apiKey ?? process.env.FRED_API_KEY;
  }

  async isAvailable(): Promise<boolean> {
    return typeof this.apiKey === 'string' && this.apiKey.length > 0;
  }

  async fetch(params: FetchParams): Promise<RawPayload> {
    if (!this.apiKey) {
      throw new RetrievalError(
        'auth-failed',
        'fred: FRED_API_KEY is not configured. Get a free key at https://fred.stlouisfed.org/docs/api/api_key.html and set FRED_API_KEY or pass apiKey in the connector config.',
      );
    }
    const seriesId = params.entity?.id?.trim();
    if (!seriesId) {
      throw new RetrievalError(
        'invalid-request',
        'fred: entity.id (FRED series id, e.g. "GDPC1") is required.',
      );
    }
    const observationStart = strParam(params.query?.observationStart);
    const observationEnd = strParam(params.query?.observationEnd);
    const result = await fetchFredObservations(seriesId, this.apiKey, {
      observationStart,
      observationEnd,
    });
    return {
      source: this.name,
      sourceUrl: result.sourceUrl,
      capturedAt: result.capturedAt,
      contentType: 'application/json',
      rawContent: JSON.stringify(result),
      metadata: {
        seriesId,
        observationStart: result.observationStart ?? null,
        observationEnd: result.observationEnd ?? null,
        count: result.count,
      },
    };
  }
}

export interface FredFetchOptions {
  readonly observationStart?: string;
  readonly observationEnd?: string;
}

export async function fetchFredObservations(
  seriesId: string,
  apiKey: string,
  opts: FredFetchOptions = {},
): Promise<FredSeriesResult> {
  if (!seriesId) {
    throw new RetrievalError('invalid-request', 'fetchFredObservations: seriesId is required.');
  }
  if (!apiKey) {
    throw new RetrievalError('auth-failed', 'fetchFredObservations: apiKey is required.');
  }
  const qp = new URLSearchParams({
    series_id: seriesId,
    api_key: apiKey,
    file_type: 'json',
  });
  if (opts.observationStart) qp.set('observation_start', opts.observationStart);
  if (opts.observationEnd) qp.set('observation_end', opts.observationEnd);
  const url = `${FRED_BASE}/series/observations?${qp.toString()}`;
  await limiter.acquire();
  let res;
  try {
    res = await httpGet(url, {
      headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
      maxBodyBytes: RESPONSE_MAX_BYTES,
    });
  } catch (err) {
    throw translateHttpError(err, url);
  }
  let parsed: Record<string, unknown>;
  try {
    parsed = JSON.parse(res.body) as Record<string, unknown>;
  } catch (err) {
    throw new RetrievalError(
      'internal',
      `fred JSON parse failed: ${err instanceof Error ? err.message : String(err)}`,
      { url },
    );
  }
  if (parsed.error_message || parsed.error_code) {
    throw new RetrievalError(
      'invalid-request',
      `fred API error: ${parsed.error_message ?? 'unknown'} (code ${parsed.error_code ?? '?'})`,
      { url },
    );
  }
  const rawObs = Array.isArray(parsed.observations) ? parsed.observations : [];
  const observations: FredObservation[] = [];
  for (const r of rawObs) {
    if (!r || typeof r !== 'object') continue;
    const o = r as Record<string, unknown>;
    const date = typeof o.date === 'string' ? o.date : '';
    const valStr = typeof o.value === 'string' ? o.value : '';
    const value = valStr === '.' || valStr === '' ? null : Number(valStr);
    observations.push({
      date,
      value: Number.isFinite(value as number) ? (value as number) : null,
      realtimeStart: typeof o.realtime_start === 'string' ? o.realtime_start : '',
      realtimeEnd: typeof o.realtime_end === 'string' ? o.realtime_end : '',
    });
  }
  return {
    seriesId,
    realtimeStart: typeof parsed.realtime_start === 'string' ? parsed.realtime_start : '',
    realtimeEnd: typeof parsed.realtime_end === 'string' ? parsed.realtime_end : '',
    observationStart: typeof parsed.observation_start === 'string' ? parsed.observation_start : undefined,
    observationEnd: typeof parsed.observation_end === 'string' ? parsed.observation_end : undefined,
    units: typeof parsed.units === 'string' ? parsed.units : '',
    outputType: typeof parsed.output_type === 'number' ? parsed.output_type : 1,
    fileType: typeof parsed.file_type === 'string' ? parsed.file_type : 'json',
    orderBy: typeof parsed.order_by === 'string' ? parsed.order_by : 'observation_date',
    sortOrder: typeof parsed.sort_order === 'string' ? parsed.sort_order : 'asc',
    count: typeof parsed.count === 'number' ? parsed.count : observations.length,
    observations,
    sourceUrl: url,
    capturedAt: new Date().toISOString(),
  };
}

function strParam(v: unknown): string | undefined {
  return typeof v === 'string' && v.trim() ? v.trim() : undefined;
}

function translateHttpError(err: unknown, url: string): RetrievalError {
  if (err instanceof HttpError) {
    switch (err.category) {
      case 'timeout':
      case 'network':
      case 'aborted':
        return new RetrievalError('unavailable', err.message, { url });
      case 'body-too-large':
        return new RetrievalError('internal', err.message, { url });
      case 'status':
        if (err.status === 404) return new RetrievalError('no-content', err.message, { url });
        if (err.status === 401 || err.status === 403) return new RetrievalError('auth-failed', err.message, { url });
        if (err.status === 429) return new RetrievalError('rate-limited', err.message, { url });
        if (err.status === 400) return new RetrievalError('invalid-request', err.message, { url });
        return new RetrievalError('internal', err.message, { url });
    }
  }
  return new RetrievalError('internal', err instanceof Error ? err.message : String(err), { url });
}