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 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)
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
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';
|
|
import { getStockAnalysisHistory } from '../server/worldmonitor/market/v1/get-stock-analysis-history.ts';
|
|
import { MarketServiceClient } from '../src/generated/client/worldmonitor/market/v1/service_client.ts';
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const originalRedisUrl = process.env.UPSTASH_REDIS_REST_URL;
|
|
const originalRedisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
|
|
const mockChartPayload = {
|
|
chart: {
|
|
result: [
|
|
{
|
|
meta: {
|
|
currency: 'USD',
|
|
regularMarketPrice: 132,
|
|
previousClose: 131,
|
|
},
|
|
timestamp: Array.from({ length: 80 }, (_, index) => 1_700_000_000 + (index * 86_400)),
|
|
indicators: {
|
|
quote: [
|
|
{
|
|
open: Array.from({ length: 80 }, (_, index) => 100 + (index * 0.4)),
|
|
high: Array.from({ length: 80 }, (_, index) => 101 + (index * 0.4)),
|
|
low: Array.from({ length: 80 }, (_, index) => 99 + (index * 0.4)),
|
|
close: Array.from({ length: 80 }, (_, index) => 100 + (index * 0.4)),
|
|
volume: Array.from({ length: 80 }, (_, index) => 1_000_000 + (index * 5_000)),
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const mockNewsXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<rss>
|
|
<channel>
|
|
<item>
|
|
<title>Apple expands AI chip roadmap</title>
|
|
<link>https://example.com/apple-ai</link>
|
|
<pubDate>Sat, 08 Mar 2026 10:00:00 GMT</pubDate>
|
|
<source>Reuters</source>
|
|
</item>
|
|
</channel>
|
|
</rss>`;
|
|
|
|
function createRedisAwareFetch() {
|
|
const redis = new Map<string, string>();
|
|
const sortedSets = new Map<string, Array<{ member: string; score: number }>>();
|
|
|
|
const upsertSortedSet = (key: string, score: number, member: string) => {
|
|
const next = (sortedSets.get(key) ?? []).filter((item) => item.member !== member);
|
|
next.push({ member, score });
|
|
next.sort((a, b) => a.score - b.score || a.member.localeCompare(b.member));
|
|
sortedSets.set(key, next);
|
|
};
|
|
|
|
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
|
|
if (url.includes('query1.finance.yahoo.com')) {
|
|
return new Response(JSON.stringify(mockChartPayload), { status: 200 });
|
|
}
|
|
if (url.includes('news.google.com')) {
|
|
return new Response(mockNewsXml, { status: 200 });
|
|
}
|
|
|
|
if (url.startsWith(process.env.UPSTASH_REDIS_REST_URL || '')) {
|
|
const parsed = new URL(url);
|
|
if (parsed.pathname.startsWith('/get/')) {
|
|
const key = decodeURIComponent(parsed.pathname.slice('/get/'.length));
|
|
return new Response(JSON.stringify({ result: redis.get(key) ?? null }), { status: 200 });
|
|
}
|
|
if (parsed.pathname.startsWith('/set/')) {
|
|
const parts = parsed.pathname.split('/');
|
|
const key = decodeURIComponent(parts[2] || '');
|
|
const value = decodeURIComponent(parts[3] || '');
|
|
redis.set(key, value);
|
|
return new Response(JSON.stringify({ result: 'OK' }), { status: 200 });
|
|
}
|
|
if (parsed.pathname === '/pipeline') {
|
|
const commands = JSON.parse(typeof init?.body === 'string' ? init.body : '[]') as string[][];
|
|
const result = commands.map((command) => {
|
|
const [verb, key = '', ...args] = command;
|
|
if (verb === 'GET') {
|
|
return { result: redis.get(key) ?? null };
|
|
}
|
|
if (verb === 'SET') {
|
|
redis.set(key, args[0] || '');
|
|
return { result: 'OK' };
|
|
}
|
|
if (verb === 'ZADD') {
|
|
for (let index = 0; index < args.length; index += 2) {
|
|
upsertSortedSet(key, Number(args[index] || 0), args[index + 1] || '');
|
|
}
|
|
return { result: 1 };
|
|
}
|
|
if (verb === 'ZREVRANGE') {
|
|
const items = [...(sortedSets.get(key) ?? [])].sort((a, b) => b.score - a.score || a.member.localeCompare(b.member));
|
|
const start = Number(args[0] || 0);
|
|
const stop = Number(args[1] || 0);
|
|
return { result: items.slice(start, stop + 1).map((item) => item.member) };
|
|
}
|
|
if (verb === 'ZREM') {
|
|
const removals = new Set(args);
|
|
sortedSets.set(key, (sortedSets.get(key) ?? []).filter((item) => !removals.has(item.member)));
|
|
return { result: removals.size };
|
|
}
|
|
if (verb === 'EXPIRE') {
|
|
return { result: 1 };
|
|
}
|
|
throw new Error(`Unexpected pipeline command: ${verb}`);
|
|
});
|
|
return new Response(JSON.stringify(result), { status: 200 });
|
|
}
|
|
}
|
|
|
|
throw new Error(`Unexpected URL: ${url}`);
|
|
}) as typeof fetch;
|
|
}
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
if (originalRedisUrl == null) delete process.env.UPSTASH_REDIS_REST_URL;
|
|
else process.env.UPSTASH_REDIS_REST_URL = originalRedisUrl;
|
|
if (originalRedisToken == null) delete process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
else process.env.UPSTASH_REDIS_REST_TOKEN = originalRedisToken;
|
|
});
|
|
|
|
function makeSnapshot(
|
|
symbol: string,
|
|
generatedAt: string,
|
|
signalScore: number,
|
|
signal = 'Buy',
|
|
options: { withAnalystFields?: boolean } = {},
|
|
): StockAnalysisSnapshot {
|
|
const base: StockAnalysisSnapshot = {
|
|
available: true,
|
|
symbol,
|
|
name: symbol,
|
|
display: symbol,
|
|
currency: 'USD',
|
|
currentPrice: 100 + signalScore,
|
|
changePercent: 1.2,
|
|
signalScore,
|
|
signal,
|
|
trendStatus: 'Bull',
|
|
volumeStatus: 'Normal',
|
|
macdStatus: 'Bullish',
|
|
rsiStatus: 'Neutral',
|
|
summary: `${symbol} summary`,
|
|
action: 'Wait for confirmation.',
|
|
confidence: 'Medium',
|
|
technicalSummary: 'Constructive setup.',
|
|
newsSummary: 'News stable.',
|
|
whyNow: 'Momentum is improving.',
|
|
bullishFactors: ['Trend remains constructive.'],
|
|
riskFactors: ['Setup needs confirmation.'],
|
|
supportLevels: [95],
|
|
resistanceLevels: [110],
|
|
headlines: [],
|
|
ma5: 101,
|
|
ma10: 100,
|
|
ma20: 98,
|
|
ma60: 92,
|
|
biasMa5: 1,
|
|
biasMa10: 2,
|
|
biasMa20: 4,
|
|
volumeRatio5d: 1.1,
|
|
rsi12: 56,
|
|
macdDif: 1.2,
|
|
macdDea: 0.8,
|
|
macdBar: 0.4,
|
|
provider: 'rules',
|
|
model: '',
|
|
fallback: true,
|
|
newsSearched: false,
|
|
generatedAt,
|
|
analysisId: `${symbol}:${generatedAt}`,
|
|
analysisAt: Date.parse(generatedAt),
|
|
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', () => {
|
|
it('merges snapshots per symbol, dedupes identical runs, and caps retained history', () => {
|
|
const existing = {
|
|
AAPL: [
|
|
makeSnapshot('AAPL', '2026-03-08T10:00:00.000Z', 70),
|
|
makeSnapshot('AAPL', '2026-03-07T10:00:00.000Z', 66),
|
|
],
|
|
};
|
|
|
|
const incoming = [
|
|
makeSnapshot('AAPL', '2026-03-08T10:00:00.000Z', 70),
|
|
makeSnapshot('AAPL', '2026-03-09T10:00:00.000Z', 74, 'Strong buy'),
|
|
...Array.from({ length: 35 }, (_, index) =>
|
|
makeSnapshot(
|
|
'MSFT',
|
|
new Date(Date.UTC(2026, 2, index + 1, 12, 0, 0)).toISOString(),
|
|
50 + index,
|
|
)),
|
|
];
|
|
|
|
const merged = mergeStockAnalysisHistory(existing, incoming);
|
|
|
|
assert.equal(merged.AAPL?.length, 3);
|
|
assert.deepEqual(
|
|
merged.AAPL?.map((snapshot) => snapshot.generatedAt),
|
|
[
|
|
'2026-03-09T10:00:00.000Z',
|
|
'2026-03-08T10:00:00.000Z',
|
|
'2026-03-07T10:00:00.000Z',
|
|
],
|
|
);
|
|
assert.equal(merged.MSFT?.length, 32);
|
|
assert.equal(merged.MSFT?.[0]?.generatedAt, '2026-04-04T12:00:00.000Z');
|
|
assert.equal(merged.MSFT?.at(-1)?.generatedAt, '2026-03-04T12:00:00.000Z');
|
|
});
|
|
|
|
it('returns the latest snapshot per symbol ordered by recency', () => {
|
|
const history = {
|
|
NVDA: [
|
|
makeSnapshot('NVDA', '2026-03-05T09:00:00.000Z', 71),
|
|
makeSnapshot('NVDA', '2026-03-04T09:00:00.000Z', 68),
|
|
],
|
|
AAPL: [
|
|
makeSnapshot('AAPL', '2026-03-08T09:00:00.000Z', 74),
|
|
],
|
|
MSFT: [
|
|
makeSnapshot('MSFT', '2026-03-07T09:00:00.000Z', 69),
|
|
],
|
|
};
|
|
|
|
const latest = getLatestStockAnalysisSnapshots(history, 2);
|
|
|
|
assert.equal(latest.length, 2);
|
|
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', () => {
|
|
it('stores fresh analysis snapshots in Redis and serves them back in batch', async () => {
|
|
process.env.UPSTASH_REDIS_REST_URL = 'https://redis.example';
|
|
process.env.UPSTASH_REDIS_REST_TOKEN = 'token';
|
|
globalThis.fetch = createRedisAwareFetch();
|
|
|
|
const analysis = await analyzeStock({} as never, {
|
|
symbol: 'AAPL',
|
|
name: 'Apple',
|
|
includeNews: true,
|
|
});
|
|
|
|
assert.equal(analysis.available, true);
|
|
|
|
const history = await getStockAnalysisHistory({} as never, {
|
|
symbols: 'AAPL,MSFT' as never,
|
|
limitPerSymbol: 4,
|
|
includeNews: true,
|
|
});
|
|
|
|
assert.equal(history.items.length, 1);
|
|
assert.equal(history.items[0]?.symbol, 'AAPL');
|
|
assert.equal(history.items[0]?.snapshots.length, 1);
|
|
assert.equal(history.items[0]?.snapshots[0]?.signal, analysis.signal);
|
|
});
|
|
});
|
|
|
|
describe('MarketServiceClient getStockAnalysisHistory', () => {
|
|
it('serializes the shared history batch query parameters using generated names', async () => {
|
|
let requestedUrl = '';
|
|
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
requestedUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
|
return new Response(JSON.stringify({ items: [] }), { status: 200 });
|
|
}) as typeof fetch;
|
|
|
|
const client = new MarketServiceClient('');
|
|
await client.getStockAnalysisHistory({
|
|
symbols: ['AAPL', 'MSFT'],
|
|
limitPerSymbol: 4,
|
|
includeNews: true,
|
|
});
|
|
|
|
assert.match(requestedUrl, /\/api\/market\/v1\/get-stock-analysis-history\?/);
|
|
assert.match(requestedUrl, /symbols=AAPL%2CMSFT|symbols=AAPL,MSFT/);
|
|
assert.match(requestedUrl, /limit_per_symbol=4/);
|
|
assert.match(requestedUrl, /include_news=true/);
|
|
});
|
|
});
|