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:
@@ -4,13 +4,13 @@ import { format } from 'date-fns';
|
|||||||
import { Panel } from '@/components/ui/panel';
|
import { Panel } from '@/components/ui/panel';
|
||||||
import { formatCurrency } from '@/lib/format';
|
import { formatCurrency } from '@/lib/format';
|
||||||
import { InteractivePriceChart } from '@/components/charts/interactive-price-chart';
|
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 = {
|
type PriceHistoryCardProps = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
priceHistory: Array<{ date: string; close: number }>;
|
priceHistory: PriceData<Array<{ date: string; close: number }> | null>;
|
||||||
benchmarkHistory: Array<{ date: string; close: number }>;
|
benchmarkHistory: PriceData<Array<{ date: string; close: number }> | null>;
|
||||||
quote: number;
|
quote: PriceData<number | null>;
|
||||||
position: Holding | null;
|
position: Holding | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,9 +29,14 @@ function asFiniteNumber(value: string | number | null | undefined) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PriceHistoryCard(props: PriceHistoryCardProps) {
|
export function PriceHistoryCard(props: PriceHistoryCardProps) {
|
||||||
const firstPoint = props.priceHistory[0];
|
const priceHistoryValue = props.priceHistory.value;
|
||||||
const lastPoint = props.priceHistory[props.priceHistory.length - 1];
|
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 inPortfolio = props.position !== null;
|
||||||
|
const hasPriceData = priceHistoryValue !== null && priceHistoryValue.length > 0;
|
||||||
|
|
||||||
const defaultChange = firstPoint && lastPoint ? lastPoint.close - firstPoint.close : null;
|
const defaultChange = firstPoint && lastPoint ? lastPoint.close - firstPoint.close : null;
|
||||||
const defaultChangePct = firstPoint && firstPoint.close > 0 && defaultChange !== 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 statusToneClass = inPortfolio ? 'text-[#96f5bf]' : 'text-[color:var(--terminal-bright)]';
|
||||||
const performanceToneClass = change !== null && change < 0 ? 'text-[#ff9f9f]' : 'text-[#96f5bf]';
|
const performanceToneClass = change !== null && change < 0 ? 'text-[#ff9f9f]' : 'text-[#96f5bf]';
|
||||||
|
|
||||||
const chartData = props.priceHistory.map(point => ({
|
const chartData = priceHistoryValue?.map(point => ({
|
||||||
date: point.date,
|
date: point.date,
|
||||||
price: point.close
|
price: point.close
|
||||||
}));
|
})) ?? [];
|
||||||
|
|
||||||
|
const benchmarkData = benchmarkHistoryValue?.map((point) => ({
|
||||||
|
date: point.date,
|
||||||
|
price: point.close
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
const comparisonSeries: DataSeries[] = [
|
const comparisonSeries: DataSeries[] = [
|
||||||
{
|
{
|
||||||
id: 'stock',
|
id: 'stock',
|
||||||
@@ -69,22 +80,28 @@ export function PriceHistoryCard(props: PriceHistoryCardProps) {
|
|||||||
{
|
{
|
||||||
id: 'sp500',
|
id: 'sp500',
|
||||||
label: 'S&P 500',
|
label: 'S&P 500',
|
||||||
data: props.benchmarkHistory.map((point) => ({
|
data: benchmarkData,
|
||||||
date: point.date,
|
|
||||||
price: point.close
|
|
||||||
})),
|
|
||||||
color: '#8ba0b8',
|
color: '#8ba0b8',
|
||||||
type: 'line'
|
type: 'line'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const helperText = Number.isFinite(props.quote)
|
const quoteAvailable = quoteValue !== null && Number.isFinite(quoteValue);
|
||||||
? `Spot price ${formatCurrency(props.quote)}`
|
const staleIndicator = props.quote.stale ? ' (stale)' : '';
|
||||||
|
const helperText = quoteAvailable
|
||||||
|
? `Spot price ${formatCurrency(quoteValue)}${staleIndicator}`
|
||||||
: 'Spot price unavailable';
|
: 'Spot price unavailable';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title="Price chart" subtitle="Interactive chart with historical data">
|
<Panel title="Price chart" subtitle={hasPriceData ? 'Interactive chart with historical data' : 'Price data unavailable'}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{!hasPriceData && (
|
||||||
|
<div className="rounded border border-[color:var(--line-weak)] bg-[color:var(--surface-dim)] px-4 py-3">
|
||||||
|
<p className="text-sm text-[color:var(--terminal-muted)]">
|
||||||
|
Price history data is currently unavailable. This may be due to a temporary issue with the market data provider.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<div className="border-b border-[color:var(--line-weak)] px-4 py-4 sm:border-b-0 sm:border-r">
|
<div className="border-b border-[color:var(--line-weak)] px-4 py-4 sm:border-b-0 sm:border-r">
|
||||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">Portfolio status</p>
|
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">Portfolio status</p>
|
||||||
@@ -108,20 +125,22 @@ export function PriceHistoryCard(props: PriceHistoryCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InteractivePriceChart
|
{hasPriceData && (
|
||||||
data={chartData}
|
<InteractivePriceChart
|
||||||
dataSeries={comparisonSeries}
|
data={chartData}
|
||||||
defaultChartType="line"
|
dataSeries={comparisonSeries}
|
||||||
defaultTimeRange="1Y"
|
defaultChartType="line"
|
||||||
showVolume={false}
|
defaultTimeRange="1Y"
|
||||||
showToolbar={true}
|
showVolume={false}
|
||||||
height={320}
|
showToolbar={true}
|
||||||
loading={props.loading}
|
height={320}
|
||||||
formatters={{
|
loading={props.loading}
|
||||||
price: formatCurrency,
|
formatters={{
|
||||||
date: (date: string) => format(new Date(date), 'MMM dd, yyyy')
|
price: formatCurrency,
|
||||||
}}
|
date: (date: string) => format(new Date(date), 'MMM dd, yyyy')
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function InteractivePriceChart({
|
|||||||
const shouldShowVolume = showVolume && filteredData.some(isOHLCVData);
|
const shouldShowVolume = showVolume && filteredData.some(isOHLCVData);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={chartRef} className="w-full">
|
<div ref={chartRef} className="w-full" data-testid="interactive-price-chart">
|
||||||
{showToolbar && (
|
{showToolbar && (
|
||||||
<ChartToolbar
|
<ChartToolbar
|
||||||
chartType={chartType}
|
chartType={chartType}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { expect, test, type Page, type TestInfo } from '@playwright/test';
|
import { expect, test, type Page, type TestInfo } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
const PASSWORD = 'Sup3rSecure!123';
|
const PASSWORD = 'Sup3rSecure!123';
|
||||||
|
|
||||||
function toSlug(value: string) {
|
function toSlug(value: string) {
|
||||||
@@ -23,6 +25,104 @@ async function signUp(page: Page, testInfo: TestInfo) {
|
|||||||
await expect(page).toHaveURL(/\/$/, { timeout: 30_000 });
|
await expect(page).toHaveURL(/\/$/, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildMockAnalysisPayload(overrides: {
|
||||||
|
ticker?: string;
|
||||||
|
companyName?: string;
|
||||||
|
quote?: { value: number | null; stale: boolean };
|
||||||
|
priceHistory?: { value: Array<{ date: string; close: number }> | 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) => {
|
test('shows the overview skeleton while analysis is loading', async ({ page }, testInfo) => {
|
||||||
await signUp(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({
|
await route.fulfill({
|
||||||
contentType: 'application/json',
|
contentType: 'application/json',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
analysis: {
|
analysis: buildMockAnalysisPayload()
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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.getByRole('heading', { name: 'Microsoft Corporation' })).toBeVisible();
|
||||||
await expect(page.getByText('Bull vs Bear')).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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ function buildAnalysisPayload(companyName: string): CompanyAnalysis {
|
|||||||
tags: [],
|
tags: [],
|
||||||
cik: null
|
cik: null
|
||||||
},
|
},
|
||||||
quote: 100,
|
quote: { value: 100, stale: false },
|
||||||
position: null,
|
position: null,
|
||||||
priceHistory: [],
|
priceHistory: { value: [], stale: false },
|
||||||
benchmarkHistory: [],
|
benchmarkHistory: { value: [], stale: false },
|
||||||
financials: [],
|
financials: [],
|
||||||
filings: [],
|
filings: [],
|
||||||
aiReports: [],
|
aiReports: [],
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ async function buildCompanyAnalysisPayload(input: {
|
|||||||
: null;
|
: null;
|
||||||
const companyProfile = toCompanyProfile(secProfile, description);
|
const companyProfile = toCompanyProfile(secProfile, description);
|
||||||
const valuationSnapshot = deriveValuationSnapshot({
|
const valuationSnapshot = deriveValuationSnapshot({
|
||||||
quote: liveQuote,
|
quote: liveQuote.value,
|
||||||
sharesOutstanding: secProfile?.sharesOutstanding ?? null,
|
sharesOutstanding: secProfile?.sharesOutstanding ?? null,
|
||||||
revenue: keyMetrics.revenue,
|
revenue: keyMetrics.revenue,
|
||||||
cash: keyMetrics.cash,
|
cash: keyMetrics.cash,
|
||||||
@@ -238,10 +238,10 @@ async function buildCompanyAnalysisPayload(input: {
|
|||||||
tags: input.localInputs.watchlistItem?.tags ?? [],
|
tags: input.localInputs.watchlistItem?.tags ?? [],
|
||||||
cik: latestFiling?.cik ?? null
|
cik: latestFiling?.cik ?? null
|
||||||
},
|
},
|
||||||
quote: liveQuote,
|
quote: { value: liveQuote.value, stale: liveQuote.stale },
|
||||||
position: input.localInputs.holding,
|
position: input.localInputs.holding,
|
||||||
priceHistory,
|
priceHistory: { value: priceHistory.value, stale: priceHistory.stale },
|
||||||
benchmarkHistory,
|
benchmarkHistory: { value: benchmarkHistory.value, stale: benchmarkHistory.stale },
|
||||||
financials,
|
financials,
|
||||||
filings: redactedFilings.slice(0, 20),
|
filings: redactedFilings.slice(0, 20),
|
||||||
aiReports,
|
aiReports,
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ describe('price caching', () => {
|
|||||||
const first = await getQuote('MSFT');
|
const first = await getQuote('MSFT');
|
||||||
const second = await getQuote('MSFT');
|
const second = await getQuote('MSFT');
|
||||||
|
|
||||||
expect(first).toBe(123.45);
|
expect(first.value).toBe(123.45);
|
||||||
expect(second).toBe(123.45);
|
expect(first.stale).toBe(false);
|
||||||
|
expect(second.value).toBe(123.45);
|
||||||
|
expect(second.stale).toBe(false);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,8 +59,115 @@ describe('price caching', () => {
|
|||||||
const first = await getPriceHistory('MSFT');
|
const first = await getPriceHistory('MSFT');
|
||||||
const second = await getPriceHistory('MSFT');
|
const second = await getPriceHistory('MSFT');
|
||||||
|
|
||||||
expect(first).toHaveLength(2);
|
expect(first.value).toHaveLength(2);
|
||||||
expect(second).toEqual(first);
|
expect(first.stale).toBe(false);
|
||||||
|
expect(second.value).toEqual(first.value);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,38 +1,45 @@
|
|||||||
const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart';
|
const YAHOO_BASE = 'https://query1.finance.yahoo.com/v8/finance/chart';
|
||||||
const QUOTE_CACHE_TTL_MS = 1000 * 60;
|
const QUOTE_CACHE_TTL_MS = 1000 * 60;
|
||||||
const PRICE_HISTORY_CACHE_TTL_MS = 1000 * 60 * 15;
|
const PRICE_HISTORY_CACHE_TTL_MS = 1000 * 60 * 15;
|
||||||
|
const FAILURE_CACHE_TTL_MS = 1000 * 30;
|
||||||
|
|
||||||
type CacheEntry<T> = {
|
export type QuoteResult = {
|
||||||
expiresAt: number;
|
value: number | null;
|
||||||
value: T;
|
stale: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const quoteCache = new Map<string, CacheEntry<number>>();
|
export type PriceHistoryResult = {
|
||||||
const priceHistoryCache = new Map<string, CacheEntry<Array<{ date: string; close: number }>>>();
|
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<string, QuoteCacheEntry>();
|
||||||
|
const priceHistoryCache = new Map<string, PriceHistoryCacheEntry>();
|
||||||
|
|
||||||
function buildYahooChartUrl(ticker: string, params: string) {
|
function buildYahooChartUrl(ticker: string, params: string) {
|
||||||
return `${YAHOO_BASE}/${encodeURIComponent(ticker.trim().toUpperCase())}?${params}`;
|
return `${YAHOO_BASE}/${encodeURIComponent(ticker.trim().toUpperCase())}?${params}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackQuote(ticker: string) {
|
export async function getQuote(ticker: string): Promise<QuoteResult> {
|
||||||
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<number> {
|
|
||||||
const normalizedTicker = ticker.trim().toUpperCase();
|
const normalizedTicker = ticker.trim().toUpperCase();
|
||||||
const cached = quoteCache.get(normalizedTicker);
|
const cached = quoteCache.get(normalizedTicker);
|
||||||
|
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
return cached.value;
|
return { value: cached.value, stale: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
let quote = fallbackQuote(normalizedTicker);
|
const staleEntry = cached;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=1d'), {
|
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1d&range=1d'), {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -42,11 +49,14 @@ export async function getQuote(ticker: string): Promise<number> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (staleEntry?.value !== null && staleEntry?.value !== undefined) {
|
||||||
|
return { value: staleEntry.value, stale: true };
|
||||||
|
}
|
||||||
quoteCache.set(normalizedTicker, {
|
quoteCache.set(normalizedTicker, {
|
||||||
value: quote,
|
value: null,
|
||||||
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
|
expiresAt: Date.now() + FAILURE_CACHE_TTL_MS
|
||||||
});
|
});
|
||||||
return quote;
|
return { value: null, stale: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await response.json() as {
|
const payload = await response.json() as {
|
||||||
@@ -57,51 +67,37 @@ export async function getQuote(ticker: string): Promise<number> {
|
|||||||
|
|
||||||
const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice;
|
const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice;
|
||||||
if (typeof price !== 'number' || !Number.isFinite(price)) {
|
if (typeof price !== 'number' || !Number.isFinite(price)) {
|
||||||
|
if (staleEntry?.value !== null && staleEntry?.value !== undefined) {
|
||||||
|
return { value: staleEntry.value, stale: true };
|
||||||
|
}
|
||||||
quoteCache.set(normalizedTicker, {
|
quoteCache.set(normalizedTicker, {
|
||||||
value: quote,
|
value: null,
|
||||||
expiresAt: Date.now() + QUOTE_CACHE_TTL_MS
|
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 {
|
} 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<number | null> {
|
export async function getQuoteOrNull(ticker: string): Promise<number | null> {
|
||||||
const normalizedTicker = ticker.trim().toUpperCase();
|
const result = await getQuote(ticker);
|
||||||
|
return result.value;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHistoricalClosingPrices(ticker: string, dates: string[]) {
|
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<Array<{ date: string; close: number }>> {
|
export async function getPriceHistory(ticker: string): Promise<PriceHistoryResult> {
|
||||||
const normalizedTicker = ticker.trim().toUpperCase();
|
const normalizedTicker = ticker.trim().toUpperCase();
|
||||||
const cached = priceHistoryCache.get(normalizedTicker);
|
const cached = priceHistoryCache.get(normalizedTicker);
|
||||||
|
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
return cached.value;
|
return { value: cached.value, stale: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const staleEntry = cached;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1wk&range=20y'), {
|
const response = await fetch(buildYahooChartUrl(normalizedTicker, 'interval=1wk&range=20y'), {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -188,7 +187,14 @@ export async function getPriceHistory(ticker: string): Promise<Array<{ date: str
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Quote history unavailable');
|
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 payload = await response.json() as {
|
const payload = await response.json() as {
|
||||||
@@ -222,44 +228,37 @@ export async function getPriceHistory(ticker: string): Promise<Array<{ date: str
|
|||||||
})
|
})
|
||||||
.filter((entry): entry is { date: string; close: number } => entry !== null);
|
.filter((entry): entry is { date: string; close: number } => 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, {
|
priceHistoryCache.set(normalizedTicker, {
|
||||||
value: points,
|
value: null,
|
||||||
expiresAt: Date.now() + PRICE_HISTORY_CACHE_TTL_MS
|
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 {
|
} 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 = {
|
export const __pricesInternals = {
|
||||||
|
FAILURE_CACHE_TTL_MS,
|
||||||
PRICE_HISTORY_CACHE_TTL_MS,
|
PRICE_HISTORY_CACHE_TTL_MS,
|
||||||
QUOTE_CACHE_TTL_MS,
|
QUOTE_CACHE_TTL_MS,
|
||||||
resetCaches() {
|
resetCaches() {
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ function buildAnalysisPayload(companyName: string): CompanyAnalysis {
|
|||||||
tags: [],
|
tags: [],
|
||||||
cik: null
|
cik: null
|
||||||
},
|
},
|
||||||
quote: 100,
|
quote: { value: 100, stale: false },
|
||||||
position: null,
|
position: null,
|
||||||
priceHistory: [],
|
priceHistory: { value: [], stale: false },
|
||||||
benchmarkHistory: [],
|
benchmarkHistory: { value: [], stale: false },
|
||||||
financials: [],
|
financials: [],
|
||||||
filings: [],
|
filings: [],
|
||||||
aiReports: [],
|
aiReports: [],
|
||||||
|
|||||||
@@ -892,6 +892,8 @@ async function processRefreshPrices(task: Task) {
|
|||||||
const userHoldings = await listHoldingsForPriceRefresh(userId);
|
const userHoldings = await listHoldingsForPriceRefresh(userId);
|
||||||
const tickers = [...new Set(userHoldings.map((entry) => entry.ticker))];
|
const tickers = [...new Set(userHoldings.map((entry) => entry.ticker))];
|
||||||
const quotes = new Map<string, number>();
|
const quotes = new Map<string, number>();
|
||||||
|
const failedTickers: string[] = [];
|
||||||
|
const staleTickers: string[] = [];
|
||||||
const baseContext = {
|
const baseContext = {
|
||||||
counters: {
|
counters: {
|
||||||
holdings: userHoldings.length
|
holdings: userHoldings.length
|
||||||
@@ -920,8 +922,15 @@ async function processRefreshPrices(task: Task) {
|
|||||||
);
|
);
|
||||||
for (let index = 0; index < tickers.length; index += 1) {
|
for (let index = 0; index < tickers.length; index += 1) {
|
||||||
const ticker = tickers[index];
|
const ticker = tickers[index];
|
||||||
const quote = await getQuote(ticker);
|
const quoteResult = await getQuote(ticker);
|
||||||
quotes.set(ticker, quote);
|
if (quoteResult.value !== null) {
|
||||||
|
quotes.set(ticker, quoteResult.value);
|
||||||
|
if (quoteResult.stale) {
|
||||||
|
staleTickers.push(ticker);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failedTickers.push(ticker);
|
||||||
|
}
|
||||||
await setProjectionStage(
|
await setProjectionStage(
|
||||||
task,
|
task,
|
||||||
'refresh.fetch_quotes',
|
'refresh.fetch_quotes',
|
||||||
@@ -931,7 +940,9 @@ async function processRefreshPrices(task: Task) {
|
|||||||
total: tickers.length,
|
total: tickers.length,
|
||||||
unit: 'tickers',
|
unit: 'tickers',
|
||||||
counters: {
|
counters: {
|
||||||
holdings: userHoldings.length
|
holdings: userHoldings.length,
|
||||||
|
failed: failedTickers.length,
|
||||||
|
stale: staleTickers.length
|
||||||
},
|
},
|
||||||
subject: { ticker }
|
subject: { ticker }
|
||||||
})
|
})
|
||||||
@@ -941,10 +952,12 @@ async function processRefreshPrices(task: Task) {
|
|||||||
await setProjectionStage(
|
await setProjectionStage(
|
||||||
task,
|
task,
|
||||||
'refresh.persist_prices',
|
'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: {
|
counters: {
|
||||||
holdings: userHoldings.length
|
holdings: userHoldings.length,
|
||||||
|
failed: failedTickers.length,
|
||||||
|
stale: staleTickers.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -952,12 +965,22 @@ async function processRefreshPrices(task: Task) {
|
|||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
updatedCount,
|
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(
|
return buildTaskOutcome(
|
||||||
result,
|
result,
|
||||||
`Refreshed prices for ${tickers.length} tickers across ${userHoldings.length} holdings.`,
|
`${messageParts.join(' ')} across ${userHoldings.length} holdings.`,
|
||||||
{
|
{
|
||||||
progress: {
|
progress: {
|
||||||
current: tickers.length,
|
current: tickers.length,
|
||||||
@@ -966,7 +989,9 @@ async function processRefreshPrices(task: Task) {
|
|||||||
},
|
},
|
||||||
counters: {
|
counters: {
|
||||||
holdings: userHoldings.length,
|
holdings: userHoldings.length,
|
||||||
updatedCount
|
updatedCount,
|
||||||
|
failed: failedTickers.length,
|
||||||
|
stale: staleTickers.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
11
lib/types.ts
11
lib/types.ts
@@ -757,6 +757,11 @@ export type RecentDevelopments = {
|
|||||||
weeklySnapshot: RecentDevelopmentsWeeklySnapshot | null;
|
weeklySnapshot: RecentDevelopmentsWeeklySnapshot | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PriceData<T> = {
|
||||||
|
value: T;
|
||||||
|
stale: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CompanyAnalysis = {
|
export type CompanyAnalysis = {
|
||||||
company: {
|
company: {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
@@ -766,10 +771,10 @@ export type CompanyAnalysis = {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
cik: string | null;
|
cik: string | null;
|
||||||
};
|
};
|
||||||
quote: number;
|
quote: PriceData<number | null>;
|
||||||
position: Holding | null;
|
position: Holding | null;
|
||||||
priceHistory: Array<{ date: string; close: number }>;
|
priceHistory: PriceData<Array<{ date: string; close: number }> | null>;
|
||||||
benchmarkHistory: Array<{ date: string; close: number }>;
|
benchmarkHistory: PriceData<Array<{ date: string; close: number }> | null>;
|
||||||
financials: CompanyFinancialPoint[];
|
financials: CompanyFinancialPoint[];
|
||||||
filings: Filing[];
|
filings: Filing[];
|
||||||
aiReports: CompanyAiReport[];
|
aiReports: CompanyAiReport[];
|
||||||
|
|||||||
Reference in New Issue
Block a user