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:
parent
32db5e70fa
commit
082800745c
@ -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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
72
backend/src/services/fmpCache.ts
Normal file
72
backend/src/services/fmpCache.ts
Normal 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
81
backend/testFmpCache.ts
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user