diff --git a/backend/src/services/apiServer.ts b/backend/src/services/apiServer.ts index 5102db4..992ecd8 100644 --- a/backend/src/services/apiServer.ts +++ b/backend/src/services/apiServer.ts @@ -43,6 +43,7 @@ import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js'; import { OperationalEvent } from '../domain/operationalEvents.js'; import { runBacktest } from '../backtest/index.js'; import type { BacktestRequest, BacktestTimeframe } from '../backtest/types.js'; +import { fetchFmpJson, FmpFetchError } from './fmpCache.js'; import { canonicalLifecycleService, type CanonicalLifecycleProfileMeta @@ -2760,11 +2761,12 @@ RULES: if (!symbol) return res.status(400).json({ error: 'symbol required' }); const apiKey = process.env.FMP_API_KEY || 'demo'; const url = `https://financialmodelingprep.com/api/v3/profile/${symbol}?apikey=${apiKey}`; - const r = await fetch(url); - if (!r.ok) return res.status(r.status).json({ error: 'FMP profile fetch failed' }); - const data = await r.json() as any; + const data = await fetchFmpJson(url) as any; res.json(Array.isArray(data) ? data[0] ?? {} : data); } 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 }); } }); @@ -2776,11 +2778,12 @@ RULES: if (!symbol) return res.status(400).json({ error: 'symbol required' }); const apiKey = process.env.FMP_API_KEY || 'demo'; const url = `https://financialmodelingprep.com/api/v3/key-metrics/${symbol}?limit=4&apikey=${apiKey}`; - const r = await fetch(url); - if (!r.ok) return res.status(r.status).json({ error: 'FMP metrics fetch failed' }); - const data = await r.json() as any; + const data = await fetchFmpJson(url) as any; res.json(Array.isArray(data) ? data[0] ?? {} : data); } 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 }); } }); @@ -2792,11 +2795,12 @@ RULES: if (!symbol) return res.status(400).json({ error: 'symbol required' }); const apiKey = process.env.FMP_API_KEY || 'demo'; const url = `https://financialmodelingprep.com/api/v3/historical/earning_calendar/${symbol}?limit=8&apikey=${apiKey}`; - const r = await fetch(url); - if (!r.ok) return res.status(r.status).json({ error: 'FMP earnings fetch failed' }); - const data = await r.json() as any; + const data = await fetchFmpJson(url) as any; res.json({ earnings: Array.isArray(data) ? data : [] }); } 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 }); } }); @@ -2815,11 +2819,12 @@ RULES: qs.set('apikey', apiKey); qs.set('isEtf', 'false'); const url = `https://financialmodelingprep.com/api/v3/stock-screener?${qs.toString()}`; - const r = await fetch(url); - if (!r.ok) return res.status(r.status).json({ error: 'FMP screener fetch failed' }); - const data = await r.json() as any; + const data = await fetchFmpJson(url) as any; res.json({ results: Array.isArray(data) ? data : [] }); } 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 }); } }); diff --git a/backend/src/services/fmpCache.ts b/backend/src/services/fmpCache.ts new file mode 100644 index 0000000..ad5a93f --- /dev/null +++ b/backend/src/services/fmpCache.ts @@ -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; +}>; + +interface FmpCacheEntry { + expiresAt: number; + data: unknown; +} + +const fmpCache = new Map(); +const inFlightFmpRequests = new Map>(); + +let now = () => Date.now(); + +export async function fetchFmpJson(url: string, fetchJson: FetchJson = fetch): Promise { + 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; +} diff --git a/backend/testFmpCache.ts b/backend/testFmpCache.ts new file mode 100644 index 0000000..9abe179 --- /dev/null +++ b/backend/testFmpCache.ts @@ -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; +}; + +function createFetch(payloads: unknown[]) { + let calls = 0; + const fetchJson = async (): Promise => { + 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 => { + 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); +});