fix(C2): cache FMP upstream responses

Add a 30-minute in-memory cache keyed by the complete Financial Modeling Prep upstream URL so repeated profile, metrics, earnings, and screener requests do not burn the free-tier quota. The cache keeps non-OK responses out of storage so transient rate-limit or provider errors can recover on the next request.

Refs: docs/AUDIT_REDESIGN.md item C2.

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
This commit is contained in:
Saravana Achu Mac 2026-05-04 16:06:47 -07:00
parent 32db5e70fa
commit 082800745c
3 changed files with 170 additions and 12 deletions

View File

@ -43,6 +43,7 @@ import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
import { OperationalEvent } from '../domain/operationalEvents.js'; import { OperationalEvent } from '../domain/operationalEvents.js';
import { runBacktest } from '../backtest/index.js'; import { runBacktest } from '../backtest/index.js';
import type { BacktestRequest, BacktestTimeframe } from '../backtest/types.js'; import type { BacktestRequest, BacktestTimeframe } from '../backtest/types.js';
import { fetchFmpJson, FmpFetchError } from './fmpCache.js';
import { import {
canonicalLifecycleService, canonicalLifecycleService,
type CanonicalLifecycleProfileMeta type CanonicalLifecycleProfileMeta
@ -2760,11 +2761,12 @@ RULES:
if (!symbol) return res.status(400).json({ error: 'symbol required' }); if (!symbol) return res.status(400).json({ error: 'symbol required' });
const apiKey = process.env.FMP_API_KEY || 'demo'; const apiKey = process.env.FMP_API_KEY || 'demo';
const url = `https://financialmodelingprep.com/api/v3/profile/${symbol}?apikey=${apiKey}`; const url = `https://financialmodelingprep.com/api/v3/profile/${symbol}?apikey=${apiKey}`;
const r = await fetch(url); const data = await fetchFmpJson(url) as any;
if (!r.ok) return res.status(r.status).json({ error: 'FMP profile fetch failed' });
const data = await r.json() as any;
res.json(Array.isArray(data) ? data[0] ?? {} : data); res.json(Array.isArray(data) ? data[0] ?? {} : data);
} catch (error: any) { } catch (error: any) {
if (error instanceof FmpFetchError) {
return res.status(error.status).json({ error: 'FMP profile fetch failed' });
}
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
@ -2776,11 +2778,12 @@ RULES:
if (!symbol) return res.status(400).json({ error: 'symbol required' }); if (!symbol) return res.status(400).json({ error: 'symbol required' });
const apiKey = process.env.FMP_API_KEY || 'demo'; const apiKey = process.env.FMP_API_KEY || 'demo';
const url = `https://financialmodelingprep.com/api/v3/key-metrics/${symbol}?limit=4&apikey=${apiKey}`; const url = `https://financialmodelingprep.com/api/v3/key-metrics/${symbol}?limit=4&apikey=${apiKey}`;
const r = await fetch(url); const data = await fetchFmpJson(url) as any;
if (!r.ok) return res.status(r.status).json({ error: 'FMP metrics fetch failed' });
const data = await r.json() as any;
res.json(Array.isArray(data) ? data[0] ?? {} : data); res.json(Array.isArray(data) ? data[0] ?? {} : data);
} catch (error: any) { } catch (error: any) {
if (error instanceof FmpFetchError) {
return res.status(error.status).json({ error: 'FMP metrics fetch failed' });
}
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
@ -2792,11 +2795,12 @@ RULES:
if (!symbol) return res.status(400).json({ error: 'symbol required' }); if (!symbol) return res.status(400).json({ error: 'symbol required' });
const apiKey = process.env.FMP_API_KEY || 'demo'; const apiKey = process.env.FMP_API_KEY || 'demo';
const url = `https://financialmodelingprep.com/api/v3/historical/earning_calendar/${symbol}?limit=8&apikey=${apiKey}`; const url = `https://financialmodelingprep.com/api/v3/historical/earning_calendar/${symbol}?limit=8&apikey=${apiKey}`;
const r = await fetch(url); const data = await fetchFmpJson(url) as any;
if (!r.ok) return res.status(r.status).json({ error: 'FMP earnings fetch failed' });
const data = await r.json() as any;
res.json({ earnings: Array.isArray(data) ? data : [] }); res.json({ earnings: Array.isArray(data) ? data : [] });
} catch (error: any) { } catch (error: any) {
if (error instanceof FmpFetchError) {
return res.status(error.status).json({ error: 'FMP earnings fetch failed' });
}
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
@ -2815,11 +2819,12 @@ RULES:
qs.set('apikey', apiKey); qs.set('apikey', apiKey);
qs.set('isEtf', 'false'); qs.set('isEtf', 'false');
const url = `https://financialmodelingprep.com/api/v3/stock-screener?${qs.toString()}`; const url = `https://financialmodelingprep.com/api/v3/stock-screener?${qs.toString()}`;
const r = await fetch(url); const data = await fetchFmpJson(url) as any;
if (!r.ok) return res.status(r.status).json({ error: 'FMP screener fetch failed' });
const data = await r.json() as any;
res.json({ results: Array.isArray(data) ? data : [] }); res.json({ results: Array.isArray(data) ? data : [] });
} catch (error: any) { } catch (error: any) {
if (error instanceof FmpFetchError) {
return res.status(error.status).json({ error: 'FMP screener fetch failed' });
}
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });

View File

@ -0,0 +1,72 @@
export const FMP_CACHE_TTL_MS = 30 * 60 * 1000;
export class FmpFetchError extends Error {
constructor(public readonly status: number) {
super(`FMP fetch failed with status ${status}`);
this.name = 'FmpFetchError';
}
}
type FetchJson = (url: string) => Promise<{
ok: boolean;
status: number;
json: () => Promise<unknown>;
}>;
interface FmpCacheEntry {
expiresAt: number;
data: unknown;
}
const fmpCache = new Map<string, FmpCacheEntry>();
const inFlightFmpRequests = new Map<string, Promise<unknown>>();
let now = () => Date.now();
export async function fetchFmpJson(url: string, fetchJson: FetchJson = fetch): Promise<unknown> {
const cached = fmpCache.get(url);
const currentTime = now();
if (cached && cached.expiresAt > currentTime) {
return cached.data;
}
if (cached) {
fmpCache.delete(url);
}
const inFlight = inFlightFmpRequests.get(url);
if (inFlight) {
return inFlight;
}
const request = fetchJson(url)
.then(async (response) => {
if (!response.ok) {
throw new FmpFetchError(response.status);
}
const data = await response.json();
fmpCache.set(url, {
data,
expiresAt: now() + FMP_CACHE_TTL_MS,
});
return data;
})
.finally(() => {
inFlightFmpRequests.delete(url);
});
inFlightFmpRequests.set(url, request);
return request;
}
export function clearFmpCacheForTests() {
fmpCache.clear();
inFlightFmpRequests.clear();
now = () => Date.now();
}
export function setFmpCacheNowForTests(nowProvider: () => number) {
now = nowProvider;
}

81
backend/testFmpCache.ts Normal file
View File

@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import {
clearFmpCacheForTests,
fetchFmpJson,
FMP_CACHE_TTL_MS,
FmpFetchError,
setFmpCacheNowForTests,
} from './src/services/fmpCache.js';
type MockResponse = {
ok: boolean;
status: number;
json: () => Promise<unknown>;
};
function createFetch(payloads: unknown[]) {
let calls = 0;
const fetchJson = async (): Promise<MockResponse> => {
const payload = payloads[calls] ?? payloads[payloads.length - 1];
calls += 1;
return {
ok: true,
status: 200,
json: async () => payload,
};
};
return {
fetchJson,
get calls() {
return calls;
},
};
}
async function main() {
clearFmpCacheForTests();
let now = 1_000;
setFmpCacheNowForTests(() => now);
const url = 'https://financialmodelingprep.com/api/v3/profile/AAPL?apikey=test';
const otherUrl = 'https://financialmodelingprep.com/api/v3/profile/MSFT?apikey=test';
const fetcher = createFetch([{ symbol: 'AAPL' }, { symbol: 'AAPL-refresh' }]);
assert.deepEqual(await fetchFmpJson(url, fetcher.fetchJson), { symbol: 'AAPL' });
assert.deepEqual(await fetchFmpJson(url, fetcher.fetchJson), { symbol: 'AAPL' });
assert.equal(fetcher.calls, 1, 'same full URL should be served from cache before TTL expiry');
assert.deepEqual(await fetchFmpJson(otherUrl, fetcher.fetchJson), { symbol: 'AAPL-refresh' });
assert.equal(fetcher.calls, 2, 'different full URL should get its own cache entry');
now += FMP_CACHE_TTL_MS + 1;
assert.deepEqual(await fetchFmpJson(url, fetcher.fetchJson), { symbol: 'AAPL-refresh' });
assert.equal(fetcher.calls, 3, 'expired cache entry should be refetched');
clearFmpCacheForTests();
let failingCalls = 0;
const failingFetch = async (): Promise<MockResponse> => {
failingCalls += 1;
return {
ok: false,
status: 429,
json: async () => ({ error: 'rate limit' }),
};
};
await assert.rejects(
() => fetchFmpJson(url, failingFetch),
(error) => error instanceof FmpFetchError && error.status === 429,
);
await assert.rejects(() => fetchFmpJson(url, failingFetch));
assert.equal(failingCalls, 2, 'failed upstream responses should not be cached');
clearFmpCacheForTests();
console.log('FMP cache tests passed');
}
main().catch((error) => {
console.error(error);
process.exit(1);
});