mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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:
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user