/**
* 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 });
}