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
|
format: double
|
||||||
engineVersion:
|
engineVersion:
|
||||||
type: string
|
type: string
|
||||||
|
analystConsensus:
|
||||||
|
$ref: '#/components/schemas/AnalystConsensus'
|
||||||
|
priceTarget:
|
||||||
|
$ref: '#/components/schemas/PriceTarget'
|
||||||
|
recentUpgrades:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/UpgradeDowngrade'
|
||||||
StockAnalysisHeadline:
|
StockAnalysisHeadline:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -1237,6 +1245,65 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
description: 'Warning: Values > 2^53 may lose precision in JavaScript'
|
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:
|
GetStockAnalysisHistoryRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -78,4 +78,35 @@ message AnalyzeStockResponse {
|
|||||||
double stop_loss = 44;
|
double stop_loss = 44;
|
||||||
double take_profit = 45;
|
double take_profit = 45;
|
||||||
string engine_version = 46;
|
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 {
|
import type {
|
||||||
AnalyzeStockRequest,
|
AnalyzeStockRequest,
|
||||||
AnalyzeStockResponse,
|
AnalyzeStockResponse,
|
||||||
|
AnalystConsensus,
|
||||||
|
PriceTarget,
|
||||||
|
UpgradeDowngrade,
|
||||||
ServerContext,
|
ServerContext,
|
||||||
StockAnalysisHeadline,
|
StockAnalysisHeadline,
|
||||||
} from '../../../../src/generated/server/worldmonitor/market/v1/service_server';
|
} 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 CACHE_TTL_SECONDS = 900;
|
||||||
const NEWS_LIMIT = 5;
|
const NEWS_LIMIT = 5;
|
||||||
const BIAS_THRESHOLD = 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' };
|
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 {
|
export function buildTechnicalSnapshot(candles: Candle[]): TechnicalSnapshot {
|
||||||
const closes = candles.map((candle) => candle.close);
|
const closes = candles.map((candle) => candle.close);
|
||||||
const highs = candles.map((candle) => candle.high);
|
const highs = candles.map((candle) => candle.high);
|
||||||
@@ -693,6 +810,7 @@ export function buildAnalysisResponse(params: {
|
|||||||
technical: TechnicalSnapshot;
|
technical: TechnicalSnapshot;
|
||||||
headlines: StockAnalysisHeadline[];
|
headlines: StockAnalysisHeadline[];
|
||||||
overlay: AiOverlay;
|
overlay: AiOverlay;
|
||||||
|
analystData: AnalystData;
|
||||||
includeNews: boolean;
|
includeNews: boolean;
|
||||||
analysisAt: number;
|
analysisAt: number;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
@@ -705,6 +823,7 @@ export function buildAnalysisResponse(params: {
|
|||||||
technical,
|
technical,
|
||||||
headlines,
|
headlines,
|
||||||
overlay,
|
overlay,
|
||||||
|
analystData,
|
||||||
includeNews,
|
includeNews,
|
||||||
analysisAt,
|
analysisAt,
|
||||||
generatedAt,
|
generatedAt,
|
||||||
@@ -764,6 +883,9 @@ export function buildAnalysisResponse(params: {
|
|||||||
stopLoss,
|
stopLoss,
|
||||||
takeProfit,
|
takeProfit,
|
||||||
engineVersion: STOCK_ANALYSIS_ENGINE_VERSION,
|
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,
|
stopLoss: 0,
|
||||||
takeProfit: 0,
|
takeProfit: 0,
|
||||||
engineVersion: STOCK_ANALYSIS_ENGINE_VERSION,
|
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 name = (req.name || symbol).trim().slice(0, 120) || symbol;
|
||||||
const includeNews = req.includeNews === true;
|
const includeNews = req.includeNews === true;
|
||||||
const nameSuffix = name !== symbol ? `:${name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 30).toLowerCase()}` : '';
|
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 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;
|
if (!history) return null;
|
||||||
|
|
||||||
const technical = buildTechnicalSnapshot(history.candles);
|
const technical = buildTechnicalSnapshot(history.candles);
|
||||||
@@ -848,6 +976,7 @@ export async function analyzeStock(
|
|||||||
technical,
|
technical,
|
||||||
headlines,
|
headlines,
|
||||||
overlay,
|
overlay,
|
||||||
|
analystData,
|
||||||
includeNews,
|
includeNews,
|
||||||
analysisAt,
|
analysisAt,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getFallbackOverlay,
|
getFallbackOverlay,
|
||||||
signalDirection,
|
signalDirection,
|
||||||
type Candle,
|
type Candle,
|
||||||
|
type AnalystData,
|
||||||
STOCK_ANALYSIS_ENGINE_VERSION,
|
STOCK_ANALYSIS_ENGINE_VERSION,
|
||||||
} from './analyze-stock';
|
} from './analyze-stock';
|
||||||
import {
|
import {
|
||||||
@@ -136,6 +137,11 @@ async function _ensureHistoricalAnalysisLedger(
|
|||||||
const analysisAt = candles[index]?.timestamp || 0;
|
const analysisAt = candles[index]?.timestamp || 0;
|
||||||
if (!analysisAt) continue;
|
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({
|
generated.push(buildAnalysisResponse({
|
||||||
symbol,
|
symbol,
|
||||||
name,
|
name,
|
||||||
@@ -143,6 +149,7 @@ async function _ensureHistoricalAnalysisLedger(
|
|||||||
technical,
|
technical,
|
||||||
headlines: [],
|
headlines: [],
|
||||||
overlay: getFallbackOverlay(name, technical, []),
|
overlay: getFallbackOverlay(name, technical, []),
|
||||||
|
analystData: emptyAnalyst,
|
||||||
includeNews: false,
|
includeNews: false,
|
||||||
analysisAt,
|
analysisAt,
|
||||||
generatedAt: new Date(analysisAt).toISOString(),
|
generatedAt: new Date(analysisAt).toISOString(),
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ import {
|
|||||||
fetchRadiationWatch,
|
fetchRadiationWatch,
|
||||||
} from '@/services';
|
} from '@/services';
|
||||||
import { getMarketWatchlistEntries } from '@/services/market-watchlist';
|
import { getMarketWatchlistEntries } from '@/services/market-watchlist';
|
||||||
import { fetchStockAnalysesForTargets, getStockAnalysisTargets } from '@/services/stock-analysis';
|
import { fetchStockAnalysesForTargets, getStockAnalysisTargets, type StockAnalysisResult } from '@/services/stock-analysis';
|
||||||
import {
|
import {
|
||||||
fetchStockBacktestsForTargets,
|
fetchStockBacktestsForTargets,
|
||||||
fetchStoredStockBacktests,
|
fetchStoredStockBacktests,
|
||||||
@@ -1227,7 +1227,25 @@ export class DataLoaderManager implements AppModule {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextHistory = mergeStockAnalysisHistory(storedHistory, results);
|
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) {
|
} catch (error) {
|
||||||
console.error('[StockAnalysis] failed:', error);
|
console.error('[StockAnalysis] failed:', error);
|
||||||
const cachedHistory = await fetchStockAnalysisHistory().catch(() => ({}));
|
const cachedHistory = await fetchStockAnalysisHistory().catch(() => ({}));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Panel } from './Panel';
|
import { Panel } from './Panel';
|
||||||
import { t } from '@/services/i18n';
|
import { t } from '@/services/i18n';
|
||||||
import type { StockAnalysisResult } from '@/services/stock-analysis';
|
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 { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||||
import type { StockAnalysisHistory } from '@/services/stock-analysis-history';
|
import type { StockAnalysisHistory } from '@/services/stock-analysis-history';
|
||||||
import { sparkline } from '@/utils/sparkline';
|
import { sparkline } from '@/utils/sparkline';
|
||||||
@@ -130,7 +131,114 @@ export class StockAnalysisPanel extends Panel {
|
|||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
${headlines ? `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">${headlines}</div>` : ''}
|
${headlines ? `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:8px">${headlines}</div>` : ''}
|
||||||
|
${this.renderAnalystConsensus(item)}
|
||||||
</section>
|
</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;
|
stopLoss: number;
|
||||||
takeProfit: number;
|
takeProfit: number;
|
||||||
engineVersion: string;
|
engineVersion: string;
|
||||||
|
analystConsensus?: AnalystConsensus;
|
||||||
|
priceTarget?: PriceTarget;
|
||||||
|
recentUpgrades: UpgradeDowngrade[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StockAnalysisHeadline {
|
export interface StockAnalysisHeadline {
|
||||||
@@ -229,6 +232,33 @@ export interface StockAnalysisHeadline {
|
|||||||
publishedAt: number;
|
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 {
|
export interface GetStockAnalysisHistoryRequest {
|
||||||
symbols: string[];
|
symbols: string[];
|
||||||
limitPerSymbol: number;
|
limitPerSymbol: number;
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ export interface AnalyzeStockResponse {
|
|||||||
stopLoss: number;
|
stopLoss: number;
|
||||||
takeProfit: number;
|
takeProfit: number;
|
||||||
engineVersion: string;
|
engineVersion: string;
|
||||||
|
analystConsensus?: AnalystConsensus;
|
||||||
|
priceTarget?: PriceTarget;
|
||||||
|
recentUpgrades: UpgradeDowngrade[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StockAnalysisHeadline {
|
export interface StockAnalysisHeadline {
|
||||||
@@ -229,6 +232,33 @@ export interface StockAnalysisHeadline {
|
|||||||
publishedAt: number;
|
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 {
|
export interface GetStockAnalysisHistoryRequest {
|
||||||
symbols: string[];
|
symbols: string[];
|
||||||
limitPerSymbol: number;
|
limitPerSymbol: number;
|
||||||
|
|||||||
@@ -63,6 +63,27 @@ export function getLatestStockAnalysisSnapshots(history: StockAnalysisHistory, l
|
|||||||
.slice(0, limit);
|
.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(
|
export function hasFreshStockAnalysisHistory(
|
||||||
history: StockAnalysisHistory,
|
history: StockAnalysisHistory,
|
||||||
symbols: string[],
|
symbols: string[],
|
||||||
@@ -70,11 +91,7 @@ export function hasFreshStockAnalysisHistory(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (symbols.length === 0) return false;
|
if (symbols.length === 0) return false;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return symbols.every((symbol) => {
|
return symbols.every((symbol) => isFreshSnapshot(history[symbol]?.[0], now, maxAgeMs));
|
||||||
const latest = history[symbol]?.[0];
|
|
||||||
const ts = Date.parse(latest?.generatedAt || '');
|
|
||||||
return !!latest?.available && Number.isFinite(ts) && (now - ts) <= maxAgeMs;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMissingOrStaleStockAnalysisSymbols(
|
export function getMissingOrStaleStockAnalysisSymbols(
|
||||||
@@ -83,11 +100,7 @@ export function getMissingOrStaleStockAnalysisSymbols(
|
|||||||
maxAgeMs = STOCK_ANALYSIS_FRESH_MS,
|
maxAgeMs = STOCK_ANALYSIS_FRESH_MS,
|
||||||
): string[] {
|
): string[] {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return symbols.filter((symbol) => {
|
return symbols.filter((symbol) => !isFreshSnapshot(history[symbol]?.[0], now, maxAgeMs));
|
||||||
const latest = history[symbol]?.[0];
|
|
||||||
const ts = Date.parse(latest?.generatedAt || '');
|
|
||||||
return !(latest?.available && Number.isFinite(ts) && (now - ts) <= maxAgeMs);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStockAnalysisHistory(
|
export async function fetchStockAnalysisHistory(
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { afterEach, describe, it } from 'node:test';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getLatestStockAnalysisSnapshots,
|
getLatestStockAnalysisSnapshots,
|
||||||
|
getMissingOrStaleStockAnalysisSymbols,
|
||||||
|
hasFreshStockAnalysisHistory,
|
||||||
mergeStockAnalysisHistory,
|
mergeStockAnalysisHistory,
|
||||||
|
STOCK_ANALYSIS_FRESH_MS,
|
||||||
type StockAnalysisSnapshot,
|
type StockAnalysisSnapshot,
|
||||||
} from '../src/services/stock-analysis-history.ts';
|
} from '../src/services/stock-analysis-history.ts';
|
||||||
import { analyzeStock } from '../server/worldmonitor/market/v1/analyze-stock.ts';
|
import { analyzeStock } from '../server/worldmonitor/market/v1/analyze-stock.ts';
|
||||||
@@ -140,8 +143,9 @@ function makeSnapshot(
|
|||||||
generatedAt: string,
|
generatedAt: string,
|
||||||
signalScore: number,
|
signalScore: number,
|
||||||
signal = 'Buy',
|
signal = 'Buy',
|
||||||
|
options: { withAnalystFields?: boolean } = {},
|
||||||
): StockAnalysisSnapshot {
|
): StockAnalysisSnapshot {
|
||||||
return {
|
const base: StockAnalysisSnapshot = {
|
||||||
available: true,
|
available: true,
|
||||||
symbol,
|
symbol,
|
||||||
name: symbol,
|
name: symbol,
|
||||||
@@ -188,7 +192,13 @@ function makeSnapshot(
|
|||||||
stopLoss: 95,
|
stopLoss: 95,
|
||||||
takeProfit: 110,
|
takeProfit: 110,
|
||||||
engineVersion: 'v2',
|
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', () => {
|
describe('stock analysis history helpers', () => {
|
||||||
@@ -247,6 +257,36 @@ describe('stock analysis history helpers', () => {
|
|||||||
assert.equal(latest[0]?.symbol, 'AAPL');
|
assert.equal(latest[0]?.symbol, 'AAPL');
|
||||||
assert.equal(latest[1]?.symbol, 'MSFT');
|
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', () => {
|
describe('server-backed stock analysis history', () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { afterEach, describe, it } from 'node:test';
|
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';
|
import { MarketServiceClient } from '../src/generated/client/worldmonitor/market/v1/service_client.ts';
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
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"?>
|
const mockNewsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<rss>
|
<rss>
|
||||||
<channel>
|
<channel>
|
||||||
@@ -62,9 +92,12 @@ describe('analyzeStock handler', () => {
|
|||||||
it('builds a structured fallback report from Yahoo history and RSS headlines', async () => {
|
it('builds a structured fallback report from Yahoo history and RSS headlines', async () => {
|
||||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.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 });
|
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')) {
|
if (url.includes('news.google.com')) {
|
||||||
return new Response(mockNewsXml, { status: 200 });
|
return new Response(mockNewsXml, { status: 200 });
|
||||||
}
|
}
|
||||||
@@ -93,6 +126,150 @@ describe('analyzeStock handler', () => {
|
|||||||
assert.equal(response.headlines.length, 2);
|
assert.equal(response.headlines.length, 2);
|
||||||
assert.match(response.summary, /apple/i);
|
assert.match(response.summary, /apple/i);
|
||||||
assert.ok(response.bullishFactors.length > 0);
|
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