feat(stocks): add analyst consensus + price targets to stock analysis panel (#2926)

* feat(stocks): add analyst consensus + price targets to stock analysis panel

Shows recommendation trend (strongBuy/buy/hold/sell), price target range
(high/low/median vs current), and recent upgrade/downgrade actions with
firm names. Data from Yahoo Finance quoteSummary.

* chore: regenerate proto types and OpenAPI docs

* fix(stocks): fallback median to mean + use stock currency for price targets

* fix(stocks): drop fake $0 price targets and force refetch for pre-rollout snapshots

- Make PriceTarget high/low/mean/median/current optional in proto so partial
  Yahoo financialData payloads stop materializing as $0.00 cells in the panel.
- fetchYahooAnalystData now passes undefined (via optionalPositive) when a
  field is missing or non-positive, instead of coercing to 0.
- StockAnalysisPanel.renderPriceTarget skips Low/High cells entirely when the
  upstream value is missing and falls back to a Median + Analysts view.
- Add field-presence freshness check in stock-analysis-history: snapshots
  written before the analyst-revisions rollout (no analystConsensus and no
  priceTarget) are now classified as stale even when their generatedAt is
  inside the freshness window, so the data loader forces a live refetch.
- Tests cover undefined targets path, missing financialData path, and the
  three field-presence freshness branches.

* fix(stocks): preserve fresh snapshots on partial refetch + accept median-only targets

- loadStockAnalysis now merges still-fresh cached symbols with refetched
  live results so a partial refetch does not shrink the rendered watchlist
- renderAnalystConsensus accepts median-only price targets (not just mean)
This commit is contained in:
Elie Habib
2026-04-11 14:26:36 +04:00
committed by GitHub
parent 2ca4b7e60e
commit 889fa62849
12 changed files with 668 additions and 18 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1224,6 +1224,14 @@ components:
format: double
engineVersion:
type: string
analystConsensus:
$ref: '#/components/schemas/AnalystConsensus'
priceTarget:
$ref: '#/components/schemas/PriceTarget'
recentUpgrades:
type: array
items:
$ref: '#/components/schemas/UpgradeDowngrade'
StockAnalysisHeadline:
type: object
properties:
@@ -1237,6 +1245,65 @@ components:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
AnalystConsensus:
type: object
properties:
strongBuy:
type: integer
format: int32
buy:
type: integer
format: int32
hold:
type: integer
format: int32
sell:
type: integer
format: int32
strongSell:
type: integer
format: int32
total:
type: integer
format: int32
period:
type: string
PriceTarget:
type: object
properties:
high:
type: number
format: double
low:
type: number
format: double
mean:
type: number
format: double
median:
type: number
format: double
current:
type: number
format: double
numberOfAnalysts:
type: integer
format: int32
UpgradeDowngrade:
type: object
properties:
firm:
type: string
toGrade:
type: string
fromGrade:
type: string
action:
type: string
epochGradeDate:
type: integer
format: int64
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
GetStockAnalysisHistoryRequest:
type: object
properties:

View File

@@ -78,4 +78,35 @@ message AnalyzeStockResponse {
double stop_loss = 44;
double take_profit = 45;
string engine_version = 46;
AnalystConsensus analyst_consensus = 47;
PriceTarget price_target = 48;
repeated UpgradeDowngrade recent_upgrades = 49;
}
message AnalystConsensus {
int32 strong_buy = 1;
int32 buy = 2;
int32 hold = 3;
int32 sell = 4;
int32 strong_sell = 5;
int32 total = 6;
string period = 7;
}
message PriceTarget {
optional double high = 1;
optional double low = 2;
optional double mean = 3;
optional double median = 4;
optional double current = 5;
int32 number_of_analysts = 6;
}
message UpgradeDowngrade {
string firm = 1;
string to_grade = 2;
string from_grade = 3;
string action = 4;
int64 epoch_grade_date = 5 [(sebuf.http.int64_encoding) = INT64_ENCODING_NUMBER];
}

View File

@@ -1,6 +1,9 @@
import type {
AnalyzeStockRequest,
AnalyzeStockResponse,
AnalystConsensus,
PriceTarget,
UpgradeDowngrade,
ServerContext,
StockAnalysisHeadline,
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
@@ -100,6 +103,50 @@ type YahooChartResponse = {
};
};
type YahooRecommendationEntry = {
strongBuy?: number;
buy?: number;
hold?: number;
sell?: number;
strongSell?: number;
period?: string;
};
type YahooUpgradeEntry = {
firm?: string;
toGrade?: string;
fromGrade?: string;
action?: string;
epochGradeDate?: number;
};
type YahooQuoteSummaryResponse = {
quoteSummary?: {
result?: Array<{
recommendationTrend?: {
trend?: YahooRecommendationEntry[];
};
financialData?: {
targetHighPrice?: { raw?: number };
targetLowPrice?: { raw?: number };
targetMeanPrice?: { raw?: number };
targetMedianPrice?: { raw?: number };
currentPrice?: { raw?: number };
numberOfAnalystOpinions?: { raw?: number };
};
upgradeDowngradeHistory?: {
history?: YahooUpgradeEntry[];
};
}>;
};
};
export type AnalystData = {
analystConsensus: AnalystConsensus;
priceTarget: PriceTarget;
recentUpgrades: UpgradeDowngrade[];
};
const CACHE_TTL_SECONDS = 900;
const NEWS_LIMIT = 5;
const BIAS_THRESHOLD = 5;
@@ -256,6 +303,76 @@ export async function fetchYahooHistory(symbol: string): Promise<{ candles: Cand
return { candles, currency: result?.meta?.currency || 'USD' };
}
function safeRaw(field: { raw?: number } | undefined): number {
return typeof field?.raw === 'number' && Number.isFinite(field.raw) ? field.raw : 0;
}
function optionalPositive(field: { raw?: number } | undefined): number | undefined {
const raw = field?.raw;
return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : undefined;
}
const EMPTY_ANALYST_DATA: AnalystData = {
analystConsensus: { strongBuy: 0, buy: 0, hold: 0, sell: 0, strongSell: 0, total: 0, period: '' },
priceTarget: { numberOfAnalysts: 0 },
recentUpgrades: [],
};
export async function fetchYahooAnalystData(symbol: string): Promise<AnalystData> {
try {
await yahooGate();
const modules = 'recommendationTrend,financialData,upgradeDowngradeHistory';
const url = `https://query1.finance.yahoo.com/v10/finance/quoteSummary/${encodeURIComponent(symbol)}?modules=${modules}`;
const response = await fetch(url, {
headers: { 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
});
if (!response.ok) return EMPTY_ANALYST_DATA;
const data = await response.json() as YahooQuoteSummaryResponse;
const result = data.quoteSummary?.result?.[0];
if (!result) return EMPTY_ANALYST_DATA;
const currentPeriod = result.recommendationTrend?.trend?.find((t) => t.period === '0m') || result.recommendationTrend?.trend?.[0];
const analystConsensus: AnalystConsensus = currentPeriod ? {
strongBuy: typeof currentPeriod.strongBuy === 'number' ? currentPeriod.strongBuy : 0,
buy: typeof currentPeriod.buy === 'number' ? currentPeriod.buy : 0,
hold: typeof currentPeriod.hold === 'number' ? currentPeriod.hold : 0,
sell: typeof currentPeriod.sell === 'number' ? currentPeriod.sell : 0,
strongSell: typeof currentPeriod.strongSell === 'number' ? currentPeriod.strongSell : 0,
total: (typeof currentPeriod.strongBuy === 'number' ? currentPeriod.strongBuy : 0)
+ (typeof currentPeriod.buy === 'number' ? currentPeriod.buy : 0)
+ (typeof currentPeriod.hold === 'number' ? currentPeriod.hold : 0)
+ (typeof currentPeriod.sell === 'number' ? currentPeriod.sell : 0)
+ (typeof currentPeriod.strongSell === 'number' ? currentPeriod.strongSell : 0),
period: currentPeriod.period || '0m',
} : EMPTY_ANALYST_DATA.analystConsensus;
const fd = result.financialData;
const priceTarget: PriceTarget = fd ? {
high: optionalPositive(fd.targetHighPrice),
low: optionalPositive(fd.targetLowPrice),
mean: optionalPositive(fd.targetMeanPrice),
median: optionalPositive(fd.targetMedianPrice),
current: optionalPositive(fd.currentPrice),
numberOfAnalysts: safeRaw(fd.numberOfAnalystOpinions),
} : EMPTY_ANALYST_DATA.priceTarget;
const rawHistory = result.upgradeDowngradeHistory?.history ?? [];
const recentUpgrades: UpgradeDowngrade[] = rawHistory.slice(0, 5).map((entry) => ({
firm: typeof entry.firm === 'string' ? entry.firm : '',
toGrade: typeof entry.toGrade === 'string' ? entry.toGrade : '',
fromGrade: typeof entry.fromGrade === 'string' ? entry.fromGrade : '',
action: typeof entry.action === 'string' ? entry.action : '',
epochGradeDate: typeof entry.epochGradeDate === 'number' ? entry.epochGradeDate : 0,
})).filter((u) => u.firm);
return { analystConsensus, priceTarget, recentUpgrades };
} catch {
return EMPTY_ANALYST_DATA;
}
}
export function buildTechnicalSnapshot(candles: Candle[]): TechnicalSnapshot {
const closes = candles.map((candle) => candle.close);
const highs = candles.map((candle) => candle.high);
@@ -693,6 +810,7 @@ export function buildAnalysisResponse(params: {
technical: TechnicalSnapshot;
headlines: StockAnalysisHeadline[];
overlay: AiOverlay;
analystData: AnalystData;
includeNews: boolean;
analysisAt: number;
generatedAt: string;
@@ -705,6 +823,7 @@ export function buildAnalysisResponse(params: {
technical,
headlines,
overlay,
analystData,
includeNews,
analysisAt,
generatedAt,
@@ -764,6 +883,9 @@ export function buildAnalysisResponse(params: {
stopLoss,
takeProfit,
engineVersion: STOCK_ANALYSIS_ENGINE_VERSION,
analystConsensus: analystData.analystConsensus,
priceTarget: analystData.priceTarget,
recentUpgrades: analystData.recentUpgrades,
};
}
@@ -815,6 +937,9 @@ function buildEmptyAnalysisResponse(symbol: string, name: string, includeNews: b
stopLoss: 0,
takeProfit: 0,
engineVersion: STOCK_ANALYSIS_ENGINE_VERSION,
analystConsensus: EMPTY_ANALYST_DATA.analystConsensus,
priceTarget: EMPTY_ANALYST_DATA.priceTarget,
recentUpgrades: [],
};
}
@@ -830,10 +955,13 @@ export async function analyzeStock(
const name = (req.name || symbol).trim().slice(0, 120) || symbol;
const includeNews = req.includeNews === true;
const nameSuffix = name !== symbol ? `:${name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 30).toLowerCase()}` : '';
const cacheKey = `market:analyze-stock:v1:${symbol}:${includeNews ? 'news' : 'no-news'}${nameSuffix}`;
const cacheKey = `market:analyze-stock:v2:${symbol}:${includeNews ? 'news' : 'no-news'}${nameSuffix}`;
const cached = await cachedFetchJson<AnalyzeStockResponse>(cacheKey, CACHE_TTL_SECONDS, async () => {
const history = await fetchYahooHistory(symbol);
const [history, analystData] = await Promise.all([
fetchYahooHistory(symbol),
fetchYahooAnalystData(symbol),
]);
if (!history) return null;
const technical = buildTechnicalSnapshot(history.candles);
@@ -848,6 +976,7 @@ export async function analyzeStock(
technical,
headlines,
overlay,
analystData,
includeNews,
analysisAt,
generatedAt: new Date().toISOString(),

View File

@@ -12,6 +12,7 @@ import {
getFallbackOverlay,
signalDirection,
type Candle,
type AnalystData,
STOCK_ANALYSIS_ENGINE_VERSION,
} from './analyze-stock';
import {
@@ -136,6 +137,11 @@ async function _ensureHistoricalAnalysisLedger(
const analysisAt = candles[index]?.timestamp || 0;
if (!analysisAt) continue;
const emptyAnalyst: AnalystData = {
analystConsensus: { strongBuy: 0, buy: 0, hold: 0, sell: 0, strongSell: 0, total: 0, period: '' },
priceTarget: { high: 0, low: 0, mean: 0, median: 0, current: 0, numberOfAnalysts: 0 },
recentUpgrades: [],
};
generated.push(buildAnalysisResponse({
symbol,
name,
@@ -143,6 +149,7 @@ async function _ensureHistoricalAnalysisLedger(
technical,
headlines: [],
overlay: getFallbackOverlay(name, technical, []),
analystData: emptyAnalyst,
includeNews: false,
analysisAt,
generatedAt: new Date(analysisAt).toISOString(),

View File

@@ -81,7 +81,7 @@ import {
fetchRadiationWatch,
} from '@/services';
import { getMarketWatchlistEntries } from '@/services/market-watchlist';
import { fetchStockAnalysesForTargets, getStockAnalysisTargets } from '@/services/stock-analysis';
import { fetchStockAnalysesForTargets, getStockAnalysisTargets, type StockAnalysisResult } from '@/services/stock-analysis';
import {
fetchStockBacktestsForTargets,
fetchStoredStockBacktests,
@@ -1227,7 +1227,25 @@ export class DataLoaderManager implements AppModule {
return;
}
const nextHistory = mergeStockAnalysisHistory(storedHistory, results);
panel.renderAnalyses(results, nextHistory, 'live');
// Build a combined view so a partial refetch does not shrink the panel:
// preserve still-fresh cached snapshots for symbols we did NOT refetch,
// and use live results for symbols we did. Watchlist order is preserved.
const resultBySymbol = new Map(results.map((r) => [r.symbol, r]));
const combined: StockAnalysisResult[] = [];
for (const target of targets) {
const live = resultBySymbol.get(target.symbol);
if (live) {
combined.push(live);
continue;
}
const cached = storedHistory[target.symbol]?.[0];
if (cached?.available) combined.push(cached);
}
if (combined.length > 0) {
panel.renderAnalyses(combined, nextHistory, 'live');
} else {
panel.renderAnalyses(results, nextHistory, 'live');
}
} catch (error) {
console.error('[StockAnalysis] failed:', error);
const cachedHistory = await fetchStockAnalysisHistory().catch(() => ({}));

View File

@@ -1,6 +1,7 @@
import { Panel } from './Panel';
import { t } from '@/services/i18n';
import type { StockAnalysisResult } from '@/services/stock-analysis';
import type { AnalystConsensus, PriceTarget, UpgradeDowngrade } from '@/generated/client/worldmonitor/market/v1/service_client';
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
import type { StockAnalysisHistory } from '@/services/stock-analysis-history';
import { sparkline } from '@/utils/sparkline';
@@ -130,7 +131,114 @@ export class StockAnalysisPanel extends Panel {
</div>
` : ''}
${headlines ? `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">${headlines}</div>` : ''}
${this.renderAnalystConsensus(item)}
</section>
`;
}
private renderAnalystConsensus(item: StockAnalysisResult): string {
const consensus = item.analystConsensus;
const pt = item.priceTarget;
const upgrades = item.recentUpgrades;
const hasConsensus = consensus && consensus.total > 0;
const hasMean = typeof pt?.mean === 'number' && pt.mean > 0;
const hasMedian = typeof pt?.median === 'number' && pt.median > 0;
const hasPriceTarget = !!pt && pt.numberOfAnalysts > 0 && (hasMean || hasMedian);
const hasUpgrades = upgrades && upgrades.length > 0;
if (!hasConsensus && !hasPriceTarget && !hasUpgrades) return '';
return `
<div style="border-top:1px solid var(--border);margin-top:4px;padding-top:10px">
<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim);margin-bottom:8px">Analyst Consensus</div>
${hasConsensus ? this.renderRatingBar(consensus) : ''}
${hasPriceTarget ? this.renderPriceTarget(pt, item.currentPrice, item.currency) : ''}
${hasUpgrades ? this.renderRecentUpgrades(upgrades) : ''}
</div>
`;
}
private renderRatingBar(c: AnalystConsensus): string {
const total = c.total || 1;
const pct = (v: number) => ((v / total) * 100).toFixed(1);
const segments = [
{ label: 'Strong Buy', count: c.strongBuy, color: '#16a34a', pct: pct(c.strongBuy) },
{ label: 'Buy', count: c.buy, color: '#4ade80', pct: pct(c.buy) },
{ label: 'Hold', count: c.hold, color: '#facc15', pct: pct(c.hold) },
{ label: 'Sell', count: c.sell, color: '#f87171', pct: pct(c.sell) },
{ label: 'Strong Sell', count: c.strongSell, color: '#dc2626', pct: pct(c.strongSell) },
].filter((s) => s.count > 0);
const bar = segments.map((s) =>
`<div style="flex:${s.count};background:${s.color};height:8px;min-width:2px" title="${escapeHtml(s.label)}: ${s.count} (${s.pct}%)"></div>`
).join('');
const legend = segments.map((s) =>
`<span style="display:inline-flex;align-items:center;gap:3px"><span style="width:8px;height:8px;border-radius:2px;background:${s.color};display:inline-block"></span>${s.count}</span>`
).join('<span style="color:var(--border);margin:0 4px">|</span>');
return `
<div style="margin-bottom:8px">
<div style="display:flex;gap:1px;border-radius:4px;overflow:hidden;margin-bottom:4px">${bar}</div>
<div style="font-size:10px;color:var(--text-dim);display:flex;align-items:center;flex-wrap:wrap;gap:2px">${legend}<span style="margin-left:6px;color:var(--text-dim)">(${total} analysts)</span></div>
</div>
`;
}
private renderPriceTarget(pt: PriceTarget, currentPrice: number, currency: string): string {
const currSymbol = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : (currency || '$');
const isSymbolPrefix = currSymbol.length === 1;
const fmt = (v: number) => isSymbolPrefix ? `${currSymbol}${v.toFixed(2)}` : `${v.toFixed(2)} ${currSymbol}`;
const hasVal = (v: number | undefined): v is number => typeof v === 'number' && Number.isFinite(v) && v > 0;
const low = hasVal(pt.low) ? pt.low : undefined;
const high = hasVal(pt.high) ? pt.high : undefined;
const mean = hasVal(pt.mean) ? pt.mean : undefined;
const median = hasVal(pt.median) ? pt.median : undefined;
const displayMedian = median ?? mean;
if (!displayMedian) return '';
const cells: string[] = [];
if (low !== undefined) {
cells.push(`<div style="border:1px solid var(--border);padding:6px 8px;flex:1;min-width:90px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">Low</div><div style="margin-top:2px">${escapeHtml(fmt(low))}</div></div>`);
}
cells.push(`<div style="border:1px solid var(--border);padding:6px 8px;flex:1;min-width:90px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">Median</div><div style="margin-top:2px">${escapeHtml(fmt(displayMedian))}</div></div>`);
if (high !== undefined) {
cells.push(`<div style="border:1px solid var(--border);padding:6px 8px;flex:1;min-width:90px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">High</div><div style="margin-top:2px">${escapeHtml(fmt(high))}</div></div>`);
}
cells.push(`<div style="border:1px solid var(--border);padding:6px 8px;flex:1;min-width:90px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">Analysts</div><div style="margin-top:2px">${escapeHtml(String(pt.numberOfAnalysts))}</div></div>`);
if (currentPrice > 0) {
const upsidePct = ((displayMedian - currentPrice) / currentPrice) * 100;
const upsideColor = upsidePct >= 0 ? 'var(--semantic-normal)' : 'var(--semantic-critical)';
const upsideStr = `${upsidePct >= 0 ? '+' : ''}${upsidePct.toFixed(1)}%`;
cells.push(`<div style="border:1px solid var(--border);padding:6px 8px;flex:1;min-width:90px"><div style="color:var(--text-dim);text-transform:uppercase;letter-spacing:0.08em">vs Current</div><div style="margin-top:2px;color:${upsideColor}">${escapeHtml(upsideStr)}</div></div>`);
}
return `<div style="display:flex;flex-wrap:wrap;gap:8px;font-size:11px;margin-bottom:8px">${cells.join('')}</div>`;
}
private renderRecentUpgrades(upgrades: UpgradeDowngrade[]): string {
const rows = upgrades.slice(0, 3).map((u) => {
const actionColor = u.action === 'up' || u.action === 'init' ? 'var(--semantic-normal)' : u.action === 'down' ? 'var(--semantic-critical)' : 'var(--text-dim)';
const actionLabel = u.action === 'up' ? 'Upgrade' : u.action === 'down' ? 'Downgrade' : u.action === 'init' ? 'Initiated' : escapeHtml(u.action);
const gradeChange = u.fromGrade ? `${escapeHtml(u.fromGrade)}${escapeHtml(u.toGrade)}` : escapeHtml(u.toGrade);
return `
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;padding:5px 8px;border:1px solid var(--border);background:rgba(255,255,255,0.02);font-size:11px">
<span style="font-weight:500">${escapeHtml(u.firm)}</span>
<span style="color:${actionColor};white-space:nowrap">${actionLabel}</span>
<span style="color:var(--text-dim);white-space:nowrap">${gradeChange}</span>
</div>
`;
}).join('');
return `
<div style="display:grid;gap:4px">
<div style="font-size:10px;text-transform:uppercase;letter-spacing:0.08em;color:var(--text-dim)">Recent Actions</div>
${rows}
</div>
`;
}
}

View File

@@ -220,6 +220,9 @@ export interface AnalyzeStockResponse {
stopLoss: number;
takeProfit: number;
engineVersion: string;
analystConsensus?: AnalystConsensus;
priceTarget?: PriceTarget;
recentUpgrades: UpgradeDowngrade[];
}
export interface StockAnalysisHeadline {
@@ -229,6 +232,33 @@ export interface StockAnalysisHeadline {
publishedAt: number;
}
export interface AnalystConsensus {
strongBuy: number;
buy: number;
hold: number;
sell: number;
strongSell: number;
total: number;
period: string;
}
export interface PriceTarget {
high?: number;
low?: number;
mean?: number;
median?: number;
current?: number;
numberOfAnalysts: number;
}
export interface UpgradeDowngrade {
firm: string;
toGrade: string;
fromGrade: string;
action: string;
epochGradeDate: number;
}
export interface GetStockAnalysisHistoryRequest {
symbols: string[];
limitPerSymbol: number;

View File

@@ -220,6 +220,9 @@ export interface AnalyzeStockResponse {
stopLoss: number;
takeProfit: number;
engineVersion: string;
analystConsensus?: AnalystConsensus;
priceTarget?: PriceTarget;
recentUpgrades: UpgradeDowngrade[];
}
export interface StockAnalysisHeadline {
@@ -229,6 +232,33 @@ export interface StockAnalysisHeadline {
publishedAt: number;
}
export interface AnalystConsensus {
strongBuy: number;
buy: number;
hold: number;
sell: number;
strongSell: number;
total: number;
period: string;
}
export interface PriceTarget {
high?: number;
low?: number;
mean?: number;
median?: number;
current?: number;
numberOfAnalysts: number;
}
export interface UpgradeDowngrade {
firm: string;
toGrade: string;
fromGrade: string;
action: string;
epochGradeDate: number;
}
export interface GetStockAnalysisHistoryRequest {
symbols: string[];
limitPerSymbol: number;

View File

@@ -63,6 +63,27 @@ export function getLatestStockAnalysisSnapshots(history: StockAnalysisHistory, l
.slice(0, limit);
}
// Snapshots written before the analyst-revisions rollout have neither
// analystConsensus nor priceTarget fields. Treat those as stale even if
// the generatedAt timestamp is still within the freshness window so the
// loader forces a live refetch to populate the new section.
function hasAnalystSchemaFields(snapshot: StockAnalysisSnapshot | undefined): boolean {
if (!snapshot) return false;
return snapshot.analystConsensus !== undefined || snapshot.priceTarget !== undefined;
}
function isFreshSnapshot(
snapshot: StockAnalysisSnapshot | undefined,
now: number,
maxAgeMs: number,
): boolean {
if (!snapshot?.available) return false;
const ts = Date.parse(snapshot.generatedAt || '');
if (!Number.isFinite(ts) || (now - ts) > maxAgeMs) return false;
if (!hasAnalystSchemaFields(snapshot)) return false;
return true;
}
export function hasFreshStockAnalysisHistory(
history: StockAnalysisHistory,
symbols: string[],
@@ -70,11 +91,7 @@ export function hasFreshStockAnalysisHistory(
): boolean {
if (symbols.length === 0) return false;
const now = Date.now();
return symbols.every((symbol) => {
const latest = history[symbol]?.[0];
const ts = Date.parse(latest?.generatedAt || '');
return !!latest?.available && Number.isFinite(ts) && (now - ts) <= maxAgeMs;
});
return symbols.every((symbol) => isFreshSnapshot(history[symbol]?.[0], now, maxAgeMs));
}
export function getMissingOrStaleStockAnalysisSymbols(
@@ -83,11 +100,7 @@ export function getMissingOrStaleStockAnalysisSymbols(
maxAgeMs = STOCK_ANALYSIS_FRESH_MS,
): string[] {
const now = Date.now();
return symbols.filter((symbol) => {
const latest = history[symbol]?.[0];
const ts = Date.parse(latest?.generatedAt || '');
return !(latest?.available && Number.isFinite(ts) && (now - ts) <= maxAgeMs);
});
return symbols.filter((symbol) => !isFreshSnapshot(history[symbol]?.[0], now, maxAgeMs));
}
export async function fetchStockAnalysisHistory(

View File

@@ -3,7 +3,10 @@ import { afterEach, describe, it } from 'node:test';
import {
getLatestStockAnalysisSnapshots,
getMissingOrStaleStockAnalysisSymbols,
hasFreshStockAnalysisHistory,
mergeStockAnalysisHistory,
STOCK_ANALYSIS_FRESH_MS,
type StockAnalysisSnapshot,
} from '../src/services/stock-analysis-history.ts';
import { analyzeStock } from '../server/worldmonitor/market/v1/analyze-stock.ts';
@@ -140,8 +143,9 @@ function makeSnapshot(
generatedAt: string,
signalScore: number,
signal = 'Buy',
options: { withAnalystFields?: boolean } = {},
): StockAnalysisSnapshot {
return {
const base: StockAnalysisSnapshot = {
available: true,
symbol,
name: symbol,
@@ -188,7 +192,13 @@ function makeSnapshot(
stopLoss: 95,
takeProfit: 110,
engineVersion: 'v2',
recentUpgrades: [],
};
if (options.withAnalystFields) {
base.analystConsensus = { strongBuy: 1, buy: 2, hold: 3, sell: 0, strongSell: 0, total: 6, period: '0m' };
base.priceTarget = { mean: 150, median: 152, high: 180, low: 130, current: 145, numberOfAnalysts: 6 };
}
return base;
}
describe('stock analysis history helpers', () => {
@@ -247,6 +257,36 @@ describe('stock analysis history helpers', () => {
assert.equal(latest[0]?.symbol, 'AAPL');
assert.equal(latest[1]?.symbol, 'MSFT');
});
it('treats time-fresh snapshots that lack analyst fields as stale so a refetch is forced', () => {
const recent = new Date(Date.now() - 60_000).toISOString();
const history = {
AAPL: [makeSnapshot('AAPL', recent, 70)],
};
assert.equal(hasFreshStockAnalysisHistory(history, ['AAPL']), false);
assert.deepEqual(getMissingOrStaleStockAnalysisSymbols(history, ['AAPL']), ['AAPL']);
});
it('treats fresh snapshots with the new analyst fields as truly fresh', () => {
const recent = new Date(Date.now() - 60_000).toISOString();
const history = {
AAPL: [makeSnapshot('AAPL', recent, 70, 'Buy', { withAnalystFields: true })],
};
assert.equal(hasFreshStockAnalysisHistory(history, ['AAPL']), true);
assert.deepEqual(getMissingOrStaleStockAnalysisSymbols(history, ['AAPL']), []);
});
it('still treats time-stale snapshots as stale even with analyst fields', () => {
const stale = new Date(Date.now() - (STOCK_ANALYSIS_FRESH_MS * 2)).toISOString();
const history = {
AAPL: [makeSnapshot('AAPL', stale, 70, 'Buy', { withAnalystFields: true })],
};
assert.equal(hasFreshStockAnalysisHistory(history, ['AAPL']), false);
assert.deepEqual(getMissingOrStaleStockAnalysisSymbols(history, ['AAPL']), ['AAPL']);
});
});
describe('server-backed stock analysis history', () => {

View File

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { afterEach, describe, it } from 'node:test';
import { analyzeStock } from '../server/worldmonitor/market/v1/analyze-stock.ts';
import { analyzeStock, fetchYahooAnalystData } from '../server/worldmonitor/market/v1/analyze-stock.ts';
import { MarketServiceClient } from '../src/generated/client/worldmonitor/market/v1/service_client.ts';
const originalFetch = globalThis.fetch;
@@ -32,6 +32,36 @@ const mockChartPayload = {
},
};
const mockQuoteSummaryPayload = {
quoteSummary: {
result: [
{
recommendationTrend: {
trend: [
{ period: '0m', strongBuy: 12, buy: 18, hold: 6, sell: 2, strongSell: 1 },
{ period: '-1m', strongBuy: 10, buy: 16, hold: 8, sell: 3, strongSell: 1 },
],
},
financialData: {
targetHighPrice: { raw: 250.0 },
targetLowPrice: { raw: 160.0 },
targetMeanPrice: { raw: 210.5 },
targetMedianPrice: { raw: 215.0 },
currentPrice: { raw: 132.0 },
numberOfAnalystOpinions: { raw: 39 },
},
upgradeDowngradeHistory: {
history: [
{ firm: 'Morgan Stanley', toGrade: 'Overweight', fromGrade: 'Equal-Weight', action: 'up', epochGradeDate: 1710000000 },
{ firm: 'Goldman Sachs', toGrade: 'Buy', fromGrade: 'Neutral', action: 'up', epochGradeDate: 1709500000 },
{ firm: 'JP Morgan', toGrade: 'Neutral', fromGrade: 'Overweight', action: 'down', epochGradeDate: 1709000000 },
],
},
},
],
},
};
const mockNewsXml = `<?xml version="1.0" encoding="UTF-8"?>
<rss>
<channel>
@@ -62,9 +92,12 @@ describe('analyzeStock handler', () => {
it('builds a structured fallback report from Yahoo history and RSS headlines', async () => {
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes('query1.finance.yahoo.com')) {
if (url.includes('query1.finance.yahoo.com/v8/finance/chart')) {
return new Response(JSON.stringify(mockChartPayload), { status: 200 });
}
if (url.includes('query1.finance.yahoo.com/v10/finance/quoteSummary')) {
return new Response(JSON.stringify(mockQuoteSummaryPayload), { status: 200 });
}
if (url.includes('news.google.com')) {
return new Response(mockNewsXml, { status: 200 });
}
@@ -93,6 +126,150 @@ describe('analyzeStock handler', () => {
assert.equal(response.headlines.length, 2);
assert.match(response.summary, /apple/i);
assert.ok(response.bullishFactors.length > 0);
assert.ok(response.analystConsensus);
assert.equal(response.analystConsensus.strongBuy, 12);
assert.equal(response.analystConsensus.buy, 18);
assert.equal(response.analystConsensus.hold, 6);
assert.equal(response.analystConsensus.sell, 2);
assert.equal(response.analystConsensus.strongSell, 1);
assert.equal(response.analystConsensus.total, 39);
assert.ok(response.priceTarget);
assert.equal(response.priceTarget.high, 250);
assert.equal(response.priceTarget.low, 160);
assert.equal(response.priceTarget.mean, 210.5);
assert.equal(response.priceTarget.median, 215);
assert.equal(response.priceTarget.numberOfAnalysts, 39);
assert.ok(response.recentUpgrades);
assert.equal(response.recentUpgrades.length, 3);
assert.equal(response.recentUpgrades[0].firm, 'Morgan Stanley');
assert.equal(response.recentUpgrades[0].action, 'up');
assert.equal(response.recentUpgrades[0].toGrade, 'Overweight');
assert.equal(response.recentUpgrades[0].fromGrade, 'Equal-Weight');
});
});
describe('fetchYahooAnalystData', () => {
it('extracts recommendation trend, price target, and upgrade history', async () => {
globalThis.fetch = (async () => {
return new Response(JSON.stringify(mockQuoteSummaryPayload), { status: 200 });
}) as typeof fetch;
const data = await fetchYahooAnalystData('AAPL');
assert.equal(data.analystConsensus.strongBuy, 12);
assert.equal(data.analystConsensus.buy, 18);
assert.equal(data.analystConsensus.hold, 6);
assert.equal(data.analystConsensus.sell, 2);
assert.equal(data.analystConsensus.strongSell, 1);
assert.equal(data.analystConsensus.total, 39);
assert.equal(data.analystConsensus.period, '0m');
assert.equal(data.priceTarget.high, 250);
assert.equal(data.priceTarget.low, 160);
assert.equal(data.priceTarget.mean, 210.5);
assert.equal(data.priceTarget.median, 215);
assert.equal(data.priceTarget.current, 132);
assert.equal(data.priceTarget.numberOfAnalysts, 39);
assert.equal(data.recentUpgrades.length, 3);
assert.equal(data.recentUpgrades[0].firm, 'Morgan Stanley');
assert.equal(data.recentUpgrades[0].action, 'up');
assert.equal(data.recentUpgrades[1].firm, 'Goldman Sachs');
assert.equal(data.recentUpgrades[2].firm, 'JP Morgan');
assert.equal(data.recentUpgrades[2].action, 'down');
});
it('returns empty data on HTTP error', async () => {
globalThis.fetch = (async () => {
return new Response('Not Found', { status: 404 });
}) as typeof fetch;
const data = await fetchYahooAnalystData('INVALID');
assert.equal(data.analystConsensus.total, 0);
assert.equal(data.priceTarget.numberOfAnalysts, 0);
assert.equal(data.recentUpgrades.length, 0);
});
it('returns empty data on network failure', async () => {
globalThis.fetch = (async () => {
throw new Error('Network error');
}) as typeof fetch;
const data = await fetchYahooAnalystData('AAPL');
assert.equal(data.analystConsensus.total, 0);
assert.equal(data.priceTarget.numberOfAnalysts, 0);
assert.equal(data.recentUpgrades.length, 0);
});
it('handles missing modules gracefully', async () => {
globalThis.fetch = (async () => {
return new Response(JSON.stringify({
quoteSummary: { result: [{}] },
}), { status: 200 });
}) as typeof fetch;
const data = await fetchYahooAnalystData('AAPL');
assert.equal(data.analystConsensus.total, 0);
assert.equal(data.priceTarget.numberOfAnalysts, 0);
assert.equal(data.recentUpgrades.length, 0);
});
it('uses typeof guards for upstream numeric fields and omits invalid targets', async () => {
globalThis.fetch = (async () => {
return new Response(JSON.stringify({
quoteSummary: {
result: [{
recommendationTrend: {
trend: [{ period: '0m', strongBuy: 'five', buy: null, hold: 3, sell: undefined, strongSell: 0 }],
},
financialData: {
targetHighPrice: { raw: 'not a number' },
targetLowPrice: {},
numberOfAnalystOpinions: { raw: 10 },
},
}],
},
}), { status: 200 });
}) as typeof fetch;
const data = await fetchYahooAnalystData('AAPL');
assert.equal(data.analystConsensus.strongBuy, 0);
assert.equal(data.analystConsensus.buy, 0);
assert.equal(data.analystConsensus.hold, 3);
assert.equal(data.analystConsensus.sell, 0);
assert.equal(data.analystConsensus.strongSell, 0);
assert.equal(data.analystConsensus.total, 3);
assert.equal(data.priceTarget.high, undefined);
assert.equal(data.priceTarget.low, undefined);
assert.equal(data.priceTarget.mean, undefined);
assert.equal(data.priceTarget.median, undefined);
assert.equal(data.priceTarget.current, undefined);
assert.equal(data.priceTarget.numberOfAnalysts, 10);
});
it('returns undefined price target fields when financialData is entirely absent', async () => {
globalThis.fetch = (async () => {
return new Response(JSON.stringify({
quoteSummary: {
result: [{
recommendationTrend: {
trend: [{ period: '0m', strongBuy: 5, buy: 3, hold: 2, sell: 0, strongSell: 0 }],
},
}],
},
}), { status: 200 });
}) as typeof fetch;
const data = await fetchYahooAnalystData('AAPL');
assert.equal(data.analystConsensus.total, 10);
assert.equal(data.priceTarget.high, undefined);
assert.equal(data.priceTarget.low, undefined);
assert.equal(data.priceTarget.mean, undefined);
assert.equal(data.priceTarget.median, undefined);
assert.equal(data.priceTarget.numberOfAnalysts, 0);
});
});