Stop substituting synthetic market data when providers fail

- Replace synthetic fallback in getQuote()/getPriceHistory() with null returns
- Add QuoteResult/PriceHistoryResult types with { value, stale } structure
- Implement stale-while-revalidate: return cached value with stale=true on live fetch failure
- Cache failures for 30s to avoid hammering provider
- Update CompanyAnalysis type to use PriceData<T> wrapper
- Update task-processors to track failed/stale tickers explicitly
- Update price-history-card UI to show unavailable state and stale indicator
- Add comprehensive tests for failure cases
- Add e2e tests for null data, stale data, and live data scenarios

Resolves #14
This commit is contained in:
2026-03-14 23:37:12 -04:00
parent 5b68333a07
commit 529437c760
10 changed files with 496 additions and 230 deletions

View File

@@ -892,6 +892,8 @@ async function processRefreshPrices(task: Task) {
const userHoldings = await listHoldingsForPriceRefresh(userId);
const tickers = [...new Set(userHoldings.map((entry) => entry.ticker))];
const quotes = new Map<string, number>();
const failedTickers: string[] = [];
const staleTickers: string[] = [];
const baseContext = {
counters: {
holdings: userHoldings.length
@@ -920,8 +922,15 @@ async function processRefreshPrices(task: Task) {
);
for (let index = 0; index < tickers.length; index += 1) {
const ticker = tickers[index];
const quote = await getQuote(ticker);
quotes.set(ticker, quote);
const quoteResult = await getQuote(ticker);
if (quoteResult.value !== null) {
quotes.set(ticker, quoteResult.value);
if (quoteResult.stale) {
staleTickers.push(ticker);
}
} else {
failedTickers.push(ticker);
}
await setProjectionStage(
task,
'refresh.fetch_quotes',
@@ -931,7 +940,9 @@ async function processRefreshPrices(task: Task) {
total: tickers.length,
unit: 'tickers',
counters: {
holdings: userHoldings.length
holdings: userHoldings.length,
failed: failedTickers.length,
stale: staleTickers.length
},
subject: { ticker }
})
@@ -941,10 +952,12 @@ async function processRefreshPrices(task: Task) {
await setProjectionStage(
task,
'refresh.persist_prices',
`Writing refreshed prices for ${tickers.length} tickers across ${userHoldings.length} holdings`,
`Writing refreshed prices for ${quotes.size} tickers across ${userHoldings.length} holdings`,
{
counters: {
holdings: userHoldings.length
holdings: userHoldings.length,
failed: failedTickers.length,
stale: staleTickers.length
}
}
);
@@ -952,12 +965,22 @@ async function processRefreshPrices(task: Task) {
const result = {
updatedCount,
totalTickers: tickers.length
totalTickers: tickers.length,
failedTickers,
staleTickers
};
const messageParts = [`Refreshed prices for ${quotes.size}/${tickers.length} tickers`];
if (failedTickers.length > 0) {
messageParts.push(`(${failedTickers.length} unavailable)`);
}
if (staleTickers.length > 0) {
messageParts.push(`(${staleTickers.length} stale)`);
}
return buildTaskOutcome(
result,
`Refreshed prices for ${tickers.length} tickers across ${userHoldings.length} holdings.`,
`${messageParts.join(' ')} across ${userHoldings.length} holdings.`,
{
progress: {
current: tickers.length,
@@ -966,7 +989,9 @@ async function processRefreshPrices(task: Task) {
},
counters: {
holdings: userHoldings.length,
updatedCount
updatedCount,
failed: failedTickers.length,
stale: staleTickers.length
}
}
);