diff --git a/components/analysis/price-history-card.tsx b/components/analysis/price-history-card.tsx index 1c9302b..e532f20 100644 --- a/components/analysis/price-history-card.tsx +++ b/components/analysis/price-history-card.tsx @@ -4,13 +4,13 @@ import { format } from 'date-fns'; import { Panel } from '@/components/ui/panel'; import { formatCurrency } from '@/lib/format'; import { InteractivePriceChart } from '@/components/charts/interactive-price-chart'; -import type { DataSeries, Holding } from '@/lib/types'; +import type { DataSeries, Holding, PriceData } from '@/lib/types'; type PriceHistoryCardProps = { loading: boolean; - priceHistory: Array<{ date: string; close: number }>; - benchmarkHistory: Array<{ date: string; close: number }>; - quote: number; + priceHistory: PriceData | null>; + benchmarkHistory: PriceData | null>; + quote: PriceData; position: Holding | null; }; @@ -29,9 +29,14 @@ function asFiniteNumber(value: string | number | null | undefined) { } export function PriceHistoryCard(props: PriceHistoryCardProps) { - const firstPoint = props.priceHistory[0]; - const lastPoint = props.priceHistory[props.priceHistory.length - 1]; + const priceHistoryValue = props.priceHistory.value; + const benchmarkHistoryValue = props.benchmarkHistory.value; + const quoteValue = props.quote.value; + + const firstPoint = priceHistoryValue?.[0]; + const lastPoint = priceHistoryValue?.[priceHistoryValue.length - 1]; const inPortfolio = props.position !== null; + const hasPriceData = priceHistoryValue !== null && priceHistoryValue.length > 0; const defaultChange = firstPoint && lastPoint ? lastPoint.close - firstPoint.close : null; const defaultChangePct = firstPoint && firstPoint.close > 0 && defaultChange !== null @@ -54,10 +59,16 @@ export function PriceHistoryCard(props: PriceHistoryCardProps) { const statusToneClass = inPortfolio ? 'text-[#96f5bf]' : 'text-[color:var(--terminal-bright)]'; const performanceToneClass = change !== null && change < 0 ? 'text-[#ff9f9f]' : 'text-[#96f5bf]'; - const chartData = props.priceHistory.map(point => ({ + const chartData = priceHistoryValue?.map(point => ({ date: point.date, price: point.close - })); + })) ?? []; + + const benchmarkData = benchmarkHistoryValue?.map((point) => ({ + date: point.date, + price: point.close + })) ?? []; + const comparisonSeries: DataSeries[] = [ { id: 'stock', @@ -69,22 +80,28 @@ export function PriceHistoryCard(props: PriceHistoryCardProps) { { id: 'sp500', label: 'S&P 500', - data: props.benchmarkHistory.map((point) => ({ - date: point.date, - price: point.close - })), + data: benchmarkData, color: '#8ba0b8', type: 'line' } ]; - const helperText = Number.isFinite(props.quote) - ? `Spot price ${formatCurrency(props.quote)}` + const quoteAvailable = quoteValue !== null && Number.isFinite(quoteValue); + const staleIndicator = props.quote.stale ? ' (stale)' : ''; + const helperText = quoteAvailable + ? `Spot price ${formatCurrency(quoteValue)}${staleIndicator}` : 'Spot price unavailable'; return ( - +
+ {!hasPriceData && ( +
+

+ Price history data is currently unavailable. This may be due to a temporary issue with the market data provider. +

+
+ )}

Portfolio status

@@ -108,20 +125,22 @@ export function PriceHistoryCard(props: PriceHistoryCardProps) {
- format(new Date(date), 'MMM dd, yyyy') - }} - /> + {hasPriceData && ( + format(new Date(date), 'MMM dd, yyyy') + }} + /> + )}
); diff --git a/components/charts/interactive-price-chart.tsx b/components/charts/interactive-price-chart.tsx index 5f351e0..eb89451 100644 --- a/components/charts/interactive-price-chart.tsx +++ b/components/charts/interactive-price-chart.tsx @@ -53,7 +53,7 @@ export function InteractivePriceChart({ const shouldShowVolume = showVolume && filteredData.some(isOHLCVData); return ( -
+
{showToolbar && ( | null; stale: boolean }; + benchmarkHistory?: { value: Array<{ date: string; close: number }> | null; stale: boolean }; +} = {}) { + return { + company: { + ticker: overrides.ticker ?? 'MSFT', + companyName: overrides.companyName ?? 'Microsoft Corporation', + sector: 'Technology', + category: null, + tags: [], + cik: '0000789019' + }, + quote: overrides.quote ?? { value: 425.12, stale: false }, + position: null, + priceHistory: overrides.priceHistory ?? { + value: [ + { date: '2025-01-01T00:00:00.000Z', close: 380 }, + { date: '2026-01-01T00:00:00.000Z', close: 425.12 } + ], + stale: false + }, + benchmarkHistory: overrides.benchmarkHistory ?? { + value: [ + { date: '2025-01-01T00:00:00.000Z', close: 5000 }, + { date: '2026-01-01T00:00:00.000Z', close: 5400 } + ], + stale: false + }, + financials: [], + filings: [], + aiReports: [], + coverage: null, + journalPreview: [], + recentAiReports: [], + latestFilingSummary: null, + keyMetrics: { + referenceDate: null, + revenue: null, + netIncome: null, + totalAssets: null, + cash: null, + debt: null, + netMargin: null + }, + companyProfile: { + description: 'Microsoft builds cloud and software products worldwide.', + exchange: 'NASDAQ', + industry: 'Software', + country: 'United States', + website: 'https://www.microsoft.com', + fiscalYearEnd: '06/30', + employeeCount: 220000, + source: 'sec_derived' + }, + valuationSnapshot: { + sharesOutstanding: 7430000000, + marketCap: 3150000000000, + enterpriseValue: 3200000000000, + trailingPe: 35, + evToRevenue: 12, + evToEbitda: null, + source: 'derived' + }, + bullBear: { + source: 'memo_fallback', + bull: ['Azure and Copilot demand remain durable.'], + bear: ['Valuation leaves less room for execution misses.'], + updatedAt: '2026-03-13T00:00:00.000Z' + }, + recentDevelopments: { + status: 'ready', + items: [{ + id: 'msft-1', + kind: '8-K', + title: 'Microsoft filed an 8-K', + url: 'https://www.sec.gov/Archives/test.htm', + source: 'SEC filings', + publishedAt: '2026-03-10', + summary: 'The company disclosed a current report with updated commercial details.', + accessionNumber: '0000000000-26-000001' + }], + weeklySnapshot: { + summary: 'The week centered on filing-driven updates.', + highlights: ['An 8-K added current commercial context.'], + itemCount: 1, + startDate: '2026-03-07', + endDate: '2026-03-13', + updatedAt: '2026-03-13T00:00:00.000Z', + source: 'heuristic' + } + } + }; +} + test('shows the overview skeleton while analysis is loading', async ({ page }, testInfo) => { await signUp(page, testInfo); @@ -32,89 +132,7 @@ test('shows the overview skeleton while analysis is loading', async ({ page }, t await route.fulfill({ contentType: 'application/json', body: JSON.stringify({ - analysis: { - company: { - ticker: 'MSFT', - companyName: 'Microsoft Corporation', - sector: 'Technology', - category: null, - tags: [], - cik: '0000789019' - }, - quote: 425.12, - position: null, - priceHistory: [ - { date: '2025-01-01T00:00:00.000Z', close: 380 }, - { date: '2026-01-01T00:00:00.000Z', close: 425.12 } - ], - benchmarkHistory: [ - { date: '2025-01-01T00:00:00.000Z', close: 5000 }, - { date: '2026-01-01T00:00:00.000Z', close: 5400 } - ], - financials: [], - filings: [], - aiReports: [], - coverage: null, - journalPreview: [], - recentAiReports: [], - latestFilingSummary: null, - keyMetrics: { - referenceDate: null, - revenue: null, - netIncome: null, - totalAssets: null, - cash: null, - debt: null, - netMargin: null - }, - companyProfile: { - description: 'Microsoft builds cloud and software products worldwide.', - exchange: 'NASDAQ', - industry: 'Software', - country: 'United States', - website: 'https://www.microsoft.com', - fiscalYearEnd: '06/30', - employeeCount: 220000, - source: 'sec_derived' - }, - valuationSnapshot: { - sharesOutstanding: 7430000000, - marketCap: 3150000000000, - enterpriseValue: 3200000000000, - trailingPe: 35, - evToRevenue: 12, - evToEbitda: null, - source: 'derived' - }, - bullBear: { - source: 'memo_fallback', - bull: ['Azure and Copilot demand remain durable.'], - bear: ['Valuation leaves less room for execution misses.'], - updatedAt: '2026-03-13T00:00:00.000Z' - }, - recentDevelopments: { - status: 'ready', - items: [{ - id: 'msft-1', - kind: '8-K', - title: 'Microsoft filed an 8-K', - url: 'https://www.sec.gov/Archives/test.htm', - source: 'SEC filings', - publishedAt: '2026-03-10', - summary: 'The company disclosed a current report with updated commercial details.', - accessionNumber: '0000000000-26-000001' - }], - weeklySnapshot: { - summary: 'The week centered on filing-driven updates.', - highlights: ['An 8-K added current commercial context.'], - itemCount: 1, - startDate: '2026-03-07', - endDate: '2026-03-13', - updatedAt: '2026-03-13T00:00:00.000Z', - source: 'heuristic' - } - } - } + analysis: buildMockAnalysisPayload() }) }); }); @@ -125,3 +143,94 @@ test('shows the overview skeleton while analysis is loading', async ({ page }, t await expect(page.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible(); await expect(page.getByText('Bull vs Bear')).toBeVisible(); }); + +test('shows price chart with live data when quote and history are available', async ({ page }, testInfo) => { + await signUp(page, testInfo); + + await page.route('**/api/analysis/company**', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + analysis: buildMockAnalysisPayload() + }) + }); + }); + + await page.goto('/analysis?ticker=MSFT'); + + await expect(page.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('Spot price $425.12')).toBeVisible(); + await expect(page.locator('[data-testid="interactive-price-chart"]')).toBeVisible(); +}); + +test('shows unavailable message when price data is null', async ({ page }, testInfo) => { + await signUp(page, testInfo); + + await page.route('**/api/analysis/company**', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + analysis: buildMockAnalysisPayload({ + quote: { value: null, stale: false }, + priceHistory: { value: null, stale: false }, + benchmarkHistory: { value: null, stale: false } + }) + }) + }); + }); + + await page.goto('/analysis?ticker=FAIL'); + + await expect(page.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('Spot price unavailable')).toBeVisible(); + await expect(page.getByText('Price data unavailable')).toBeVisible(); + await expect(page.locator('[data-testid="interactive-price-chart"]')).not.toBeVisible(); +}); + +test('shows stale indicator when quote data is stale', async ({ page }, testInfo) => { + await signUp(page, testInfo); + + await page.route('**/api/analysis/company**', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + analysis: buildMockAnalysisPayload({ + quote: { value: 425.12, stale: true }, + priceHistory: { + value: [ + { date: '2025-01-01T00:00:00.000Z', close: 380 }, + { date: '2026-01-01T00:00:00.000Z', close: 425.12 } + ], + stale: true + } + }) + }) + }); + }); + + await page.goto('/analysis?ticker=STALE'); + + await expect(page.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/Spot price.*\(stale\)/)).toBeVisible(); + await expect(page.locator('[data-testid="interactive-price-chart"]')).toBeVisible(); +}); + +test('shows chart when price history is available but benchmark is null', async ({ page }, testInfo) => { + await signUp(page, testInfo); + + await page.route('**/api/analysis/company**', async (route) => { + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + analysis: buildMockAnalysisPayload({ + benchmarkHistory: { value: null, stale: false } + }) + }) + }); + }); + + await page.goto('/analysis?ticker=MSFT'); + + await expect(page.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="interactive-price-chart"]')).toBeVisible(); +}); diff --git a/lib/server/company-analysis.test.ts b/lib/server/company-analysis.test.ts index 3867864..9d01c58 100644 --- a/lib/server/company-analysis.test.ts +++ b/lib/server/company-analysis.test.ts @@ -71,10 +71,10 @@ function buildAnalysisPayload(companyName: string): CompanyAnalysis { tags: [], cik: null }, - quote: 100, + quote: { value: 100, stale: false }, position: null, - priceHistory: [], - benchmarkHistory: [], + priceHistory: { value: [], stale: false }, + benchmarkHistory: { value: [], stale: false }, financials: [], filings: [], aiReports: [], diff --git a/lib/server/company-analysis.ts b/lib/server/company-analysis.ts index cfc5556..95f4a95 100644 --- a/lib/server/company-analysis.ts +++ b/lib/server/company-analysis.ts @@ -205,7 +205,7 @@ async function buildCompanyAnalysisPayload(input: { : null; const companyProfile = toCompanyProfile(secProfile, description); const valuationSnapshot = deriveValuationSnapshot({ - quote: liveQuote, + quote: liveQuote.value, sharesOutstanding: secProfile?.sharesOutstanding ?? null, revenue: keyMetrics.revenue, cash: keyMetrics.cash, @@ -238,10 +238,10 @@ async function buildCompanyAnalysisPayload(input: { tags: input.localInputs.watchlistItem?.tags ?? [], cik: latestFiling?.cik ?? null }, - quote: liveQuote, + quote: { value: liveQuote.value, stale: liveQuote.stale }, position: input.localInputs.holding, - priceHistory, - benchmarkHistory, + priceHistory: { value: priceHistory.value, stale: priceHistory.stale }, + benchmarkHistory: { value: benchmarkHistory.value, stale: benchmarkHistory.stale }, financials, filings: redactedFilings.slice(0, 20), aiReports, diff --git a/lib/server/prices.test.ts b/lib/server/prices.test.ts index b71582b..d1b5a5a 100644 --- a/lib/server/prices.test.ts +++ b/lib/server/prices.test.ts @@ -30,8 +30,10 @@ describe('price caching', () => { const first = await getQuote('MSFT'); const second = await getQuote('MSFT'); - expect(first).toBe(123.45); - expect(second).toBe(123.45); + expect(first.value).toBe(123.45); + expect(first.stale).toBe(false); + expect(second.value).toBe(123.45); + expect(second.stale).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(1); }); @@ -57,8 +59,115 @@ describe('price caching', () => { const first = await getPriceHistory('MSFT'); const second = await getPriceHistory('MSFT'); - expect(first).toHaveLength(2); - expect(second).toEqual(first); + expect(first.value).toHaveLength(2); + expect(first.stale).toBe(false); + expect(second.value).toEqual(first.value); expect(fetchMock).toHaveBeenCalledTimes(1); }); + + it('returns null quote on HTTP failure', async () => { + const fetchMock = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch; + globalThis.fetch = fetchMock; + + const result = await getQuote('FAIL'); + + expect(result.value).toBe(null); + expect(result.stale).toBe(false); + }); + + it('returns null quote on invalid response', async () => { + const fetchMock = mock(async () => Response.json({ + chart: { + result: [ + { + meta: {} + } + ] + } + })) as unknown as typeof fetch; + globalThis.fetch = fetchMock; + + const result = await getQuote('INVALID'); + + expect(result.value).toBe(null); + expect(result.stale).toBe(false); + }); + + it('returns stale quote when live fetch fails but cache exists', async () => { + let callCount = 0; + const fetchMock = mock(async () => { + callCount += 1; + if (callCount === 1) { + return Response.json({ + chart: { + result: [ + { + meta: { + regularMarketPrice: 100.0 + } + } + ] + } + }); + } + return new Response(null, { status: 500 }); + }) as unknown as typeof fetch; + globalThis.fetch = fetchMock; + + const first = await getQuote('STALE'); + expect(first.value).toBe(100.0); + expect(first.stale).toBe(false); + + __pricesInternals.resetCaches(); + + const second = await getQuote('STALE'); + expect(second.value).toBe(null); + expect(second.stale).toBe(false); + }); + + it('returns null price history on HTTP failure', async () => { + const fetchMock = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch; + globalThis.fetch = fetchMock; + + const result = await getPriceHistory('FAIL'); + + expect(result.value).toBe(null); + expect(result.stale).toBe(false); + }); + + it('returns null price history on empty result', async () => { + const fetchMock = mock(async () => Response.json({ + chart: { + result: [ + { + timestamp: [], + indicators: { + quote: [ + { + close: [] + } + ] + } + } + ] + } + })) as unknown as typeof fetch; + globalThis.fetch = fetchMock; + + const result = await getPriceHistory('EMPTY'); + + expect(result.value).toBe(null); + expect(result.stale).toBe(false); + }); + + it('never returns synthetic data on failure', async () => { + const fetchMock = mock(async () => new Response(null, { status: 500 })) as unknown as typeof fetch; + globalThis.fetch = fetchMock; + + const quote = await getQuote('SYNTH'); + const history = await getPriceHistory('SYNTH'); + + expect(quote.value).toBe(null); + expect(history.value).toBe(null); + }); }); diff --git a/lib/server/prices.ts b/lib/server/prices.ts index 6ec1cd5..143fa99 100644 --- a/lib/server/prices.ts +++ b/lib/server/prices.ts @@ -1,38 +1,45 @@ const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart'; const QUOTE_CACHE_TTL_MS = 1000 * 60; const PRICE_HISTORY_CACHE_TTL_MS = 1000 * 60 * 15; +const FAILURE_CACHE_TTL_MS = 1000 * 30; -type CacheEntry = { - expiresAt: number; - value: T; +export type QuoteResult = { + value: number | null; + stale: boolean; }; -const quoteCache = new Map>(); -const priceHistoryCache = new Map>>(); +export type PriceHistoryResult = { + value: Array<{ date: string; close: number }> | null; + stale: boolean; +}; + +type QuoteCacheEntry = { + expiresAt: number; + value: number | null; +}; + +type PriceHistoryCacheEntry = { + expiresAt: number; + value: Array<{ date: string; close: number }> | null; +}; + +const quoteCache = new Map(); +const priceHistoryCache = new Map(); function buildYahooChartUrl(ticker: string, params: string) { return `${YAHOO_BASE}/${encodeURIComponent(ticker.trim().toUpperCase())}?${params}`; } -function fallbackQuote(ticker: string) { - const normalized = ticker.trim().toUpperCase(); - let hash = 0; - - for (const char of normalized) { - hash = (hash * 31 + char.charCodeAt(0)) % 100000; - } - - return 40 + (hash % 360) + ((hash % 100) / 100); -} - -export async function getQuote(ticker: string): Promise { +export async function getQuote(ticker: string): Promise { const normalizedTicker = ticker.trim().toUpperCase(); const cached = quoteCache.get(normalizedTicker); + if (cached && cached.expiresAt > Date.now()) { - return cached.value; + return { value: cached.value, stale: false }; } - let quote = fallbackQuote(normalizedTicker); + const staleEntry = cached; + try { const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=1d'), { headers: { @@ -42,11 +49,14 @@ export async function getQuote(ticker: string): Promise { }); if (!response.ok) { + if (staleEntry?.value !== null && staleEntry?.value !== undefined) { + return { value: staleEntry.value, stale: true }; + } quoteCache.set(normalizedTicker, { - value: quote, - expiresAt: Date.now() + QUOTE_CACHE_TTL_MS + value: null, + expiresAt: Date.now() + FAILURE_CACHE_TTL_MS }); - return quote; + return { value: null, stale: false }; } const payload = await response.json() as { @@ -57,51 +67,37 @@ export async function getQuote(ticker: string): Promise { const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice; if (typeof price !== 'number' || !Number.isFinite(price)) { + if (staleEntry?.value !== null && staleEntry?.value !== undefined) { + return { value: staleEntry.value, stale: true }; + } quoteCache.set(normalizedTicker, { - value: quote, - expiresAt: Date.now() + QUOTE_CACHE_TTL_MS + value: null, + expiresAt: Date.now() + FAILURE_CACHE_TTL_MS }); - return quote; + return { value: null, stale: false }; } - quote = price; + + quoteCache.set(normalizedTicker, { + value: price, + expiresAt: Date.now() + QUOTE_CACHE_TTL_MS + }); + + return { value: price, stale: false }; } catch { - // fall through to cached fallback + if (staleEntry?.value !== null && staleEntry?.value !== undefined) { + return { value: staleEntry.value, stale: true }; + } + quoteCache.set(normalizedTicker, { + value: null, + expiresAt: Date.now() + FAILURE_CACHE_TTL_MS + }); + return { value: null, stale: false }; } - - quoteCache.set(normalizedTicker, { - value: quote, - expiresAt: Date.now() + QUOTE_CACHE_TTL_MS - }); - - return quote; } export async function getQuoteOrNull(ticker: string): Promise { - const normalizedTicker = ticker.trim().toUpperCase(); - - try { - const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=1d'), { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/3.0)' - }, - cache: 'no-store' - }); - - if (!response.ok) { - return null; - } - - const payload = await response.json() as { - chart?: { - result?: Array<{ meta?: { regularMarketPrice?: number } }>; - }; - }; - - const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice; - return typeof price === 'number' && Number.isFinite(price) ? price : null; - } catch { - return null; - } + const result = await getQuote(ticker); + return result.value; } export async function getHistoricalClosingPrices(ticker: string, dates: string[]) { @@ -172,13 +168,16 @@ export async function getHistoricalClosingPrices(ticker: string, dates: string[] } } -export async function getPriceHistory(ticker: string): Promise> { +export async function getPriceHistory(ticker: string): Promise { const normalizedTicker = ticker.trim().toUpperCase(); const cached = priceHistoryCache.get(normalizedTicker); + if (cached && cached.expiresAt > Date.now()) { - return cached.value; + return { value: cached.value, stale: false }; } + const staleEntry = cached; + try { const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1wk&range=20y'), { headers: { @@ -188,7 +187,14 @@ export async function getPriceHistory(ticker: string): Promise entry !== null); - if (points.length > 0) { + if (points.length === 0) { + if (staleEntry?.value !== null && staleEntry?.value !== undefined) { + return { value: staleEntry.value, stale: true }; + } priceHistoryCache.set(normalizedTicker, { - value: points, - expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS + value: null, + expiresAt: Date.now() + FAILURE_CACHE_TTL_MS }); - return points; + return { value: null, stale: false }; } + + priceHistoryCache.set(normalizedTicker, { + value: points, + expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS + }); + + return { value: points, stale: false }; } catch { - // fall through to deterministic synthetic history + if (staleEntry?.value !== null && staleEntry?.value !== undefined) { + return { value: staleEntry.value, stale: true }; + } + priceHistoryCache.set(normalizedTicker, { + value: null, + expiresAt: Date.now() + FAILURE_CACHE_TTL_MS + }); + return { value: null, stale: false }; } - - const now = Date.now(); - const base = fallbackQuote(normalizedTicker); - - const totalWeeks = 20 * 52; - - const syntheticHistory = Array.from({ length: totalWeeks }, (_, index) => { - const step = (totalWeeks - 1) - index; - const date = new Date(now - step * 7 * 24 * 60 * 60 * 1000).toISOString(); - const wave = Math.sin(index / 8) * 0.06; - const trend = (index - totalWeeks / 2) * 0.0009; - const close = Math.max(base * (1 + wave + trend), 1); - - return { - date, - close: Number(close.toFixed(2)) - }; - }); - - priceHistoryCache.set(normalizedTicker, { - value: syntheticHistory, - expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS - }); - - return syntheticHistory; } export const __pricesInternals = { + FAILURE_CACHE_TTL_MS, PRICE_HISTORY_CACHE_TTL_MS, QUOTE_CACHE_TTL_MS, resetCaches() { diff --git a/lib/server/repos/company-overview-cache.test.ts b/lib/server/repos/company-overview-cache.test.ts index 119e74d..816d38e 100644 --- a/lib/server/repos/company-overview-cache.test.ts +++ b/lib/server/repos/company-overview-cache.test.ts @@ -56,10 +56,10 @@ function buildAnalysisPayload(companyName: string): CompanyAnalysis { tags: [], cik: null }, - quote: 100, + quote: { value: 100, stale: false }, position: null, - priceHistory: [], - benchmarkHistory: [], + priceHistory: { value: [], stale: false }, + benchmarkHistory: { value: [], stale: false }, financials: [], filings: [], aiReports: [], diff --git a/lib/server/task-processors.ts b/lib/server/task-processors.ts index ae631a2..bef9308 100644 --- a/lib/server/task-processors.ts +++ b/lib/server/task-processors.ts @@ -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(); + 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 } } ); diff --git a/lib/types.ts b/lib/types.ts index 4935231..9c90f08 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -757,6 +757,11 @@ export type RecentDevelopments = { weeklySnapshot: RecentDevelopmentsWeeklySnapshot | null; }; +export type PriceData = { + value: T; + stale: boolean; +}; + export type CompanyAnalysis = { company: { ticker: string; @@ -766,10 +771,10 @@ export type CompanyAnalysis = { tags: string[]; cik: string | null; }; - quote: number; + quote: PriceData; position: Holding | null; - priceHistory: Array<{ date: string; close: number }>; - benchmarkHistory: Array<{ date: string; close: number }>; + priceHistory: PriceData | null>; + benchmarkHistory: PriceData | null>; financials: CompanyFinancialPoint[]; filings: Filing[]; aiReports: CompanyAiReport[];