diff --git a/src/agent/indicators.ts b/src/agent/indicators.ts new file mode 100644 index 0000000..16dac49 --- /dev/null +++ b/src/agent/indicators.ts @@ -0,0 +1,119 @@ +import type { IndicatorPoint, PriceCandle } from "../shared/types"; + +export function calculateIndicators(candles: PriceCandle[]): IndicatorPoint[] { + const closes = candles.map((candle) => candle.close); + const ema12 = ema(closes, 12); + const ema26 = ema(closes, 26); + const macdLine = closes.map((_, index) => valueOrUndefined(ema12[index], ema26[index], (fast, slow) => fast - slow)); + const signalLine = ema(macdLine, 9); + const rsi = rsi14(closes); + + return candles.map((candle, index) => { + const macd = macdLine[index]; + const macdSignal = signalLine[index]; + return { + date: candle.date, + ma5: movingAverage(closes, index, 5), + ma20: movingAverage(closes, index, 20), + ma60: movingAverage(closes, index, 60), + ma120: movingAverage(closes, index, 120), + ma200: movingAverage(closes, index, 200), + rsi14: rsi[index], + macd, + macdSignal, + macdHistogram: valueOrUndefined(macd, macdSignal, (line, signal) => line - signal) + }; + }); +} + +function movingAverage(values: number[], index: number, window: number): number | undefined { + if (index + 1 < window) { + return undefined; + } + const slice = values.slice(index + 1 - window, index + 1); + return round(slice.reduce((sum, value) => sum + value, 0) / window, 4); +} + +function ema(values: Array, window: number): Array { + const result: Array = []; + const multiplier = 2 / (window + 1); + let previous: number | undefined; + let seedValues: number[] = []; + + for (const value of values) { + if (value === undefined || !Number.isFinite(value)) { + result.push(undefined); + continue; + } + + if (previous === undefined) { + seedValues.push(value); + if (seedValues.length < window) { + result.push(undefined); + continue; + } + previous = seedValues.reduce((sum, item) => sum + item, 0) / window; + result.push(round(previous, 4)); + continue; + } + + previous = value * multiplier + previous * (1 - multiplier); + result.push(round(previous, 4)); + } + + return result; +} + +function rsi14(closes: number[]): Array { + const result: Array = [undefined]; + let gains = 0; + let losses = 0; + + for (let index = 1; index < closes.length; index += 1) { + const change = closes[index] - closes[index - 1]; + const gain = Math.max(0, change); + const loss = Math.max(0, -change); + + if (index <= 14) { + gains += gain; + losses += loss; + if (index === 14) { + gains /= 14; + losses /= 14; + result.push(rsiValue(gains, losses)); + } else { + result.push(undefined); + } + continue; + } + + gains = (gains * 13 + gain) / 14; + losses = (losses * 13 + loss) / 14; + result.push(rsiValue(gains, losses)); + } + + return result; +} + +function rsiValue(gain: number, loss: number): number { + if (loss === 0) { + return 100; + } + return round(100 - 100 / (1 + gain / loss), 4); +} + +function valueOrUndefined( + left: number | undefined, + right: number | undefined, + calculate: (left: number, right: number) => number +): number | undefined { + if (left === undefined || right === undefined) { + return undefined; + } + return round(calculate(left, right), 4); +} + +function round(value: number, digits: number): number { + const factor = 10 ** digits; + return Math.round(value * factor) / factor; +} diff --git a/src/agent/provider.ts b/src/agent/provider.ts index 3413880..2ea8939 100644 --- a/src/agent/provider.ts +++ b/src/agent/provider.ts @@ -1,22 +1,146 @@ import { z } from "zod"; -import type { MarketDataProvider, StockSnapshot, WatchStock } from "../shared/types"; +import type { + FinancialStatement, + MarketDataProvider, + PriceCandle, + QuantAnalysis, + QuantAnalysisInput, + RelatedNews, + RelatedStockData, + StockSnapshot, + WatchStock +} from "../shared/types"; type FetchLike = typeof fetch; +type NasdaqAssetClass = "stocks" | "etf"; +interface NasdaqHistoricalRow { + date: string; + close: string; + volume: string; + open: string; + high: string; + low: string; +} + +interface SinaKlineRow { + day: string; + open: string; + high: string; + low: string; + close: string; + volume: string; +} export const IPADS_BASE_URL = "http://tianx.ipads-lab.se.sjtu.edu.cn:8319/v1"; export const IPADS_WIRE_API = "responses"; export const IPADS_MODEL = "gpt-5.5"; +const NASDAQ_ASSET_CLASSES: NasdaqAssetClass[] = ["stocks", "etf"]; const snapshotSchema = z.object({ price: z.number(), currency: z.string().min(1), - pe: z.number(), + pe: z.number().optional(), forwardPe: z.number().optional(), revenueGrowth: z.number().optional(), profitGrowth: z.number().optional(), nextEarningsDate: z.string().optional(), peers: z.array(z.object({ display: z.string(), pe: z.number() })).default([]), - news: z.array(z.object({ title: z.string(), url: z.string(), source: z.string().optional(), publishedAt: z.string().optional() })).default([]) + news: z.array(z.object({ title: z.string(), url: z.string(), source: z.string().optional(), publishedAt: z.string().optional() })).default([]), + asOf: z.string().optional(), + sources: z.array(z.object({ name: z.string(), url: z.string().optional(), asOf: z.string().optional() })).default([]) +}); + +const financialStatementSchema = z.object({ + period: z.string(), + reportDate: z.string().optional(), + revenue: z.number(), + netIncome: z.number(), + eps: z.number().optional(), + grossMargin: z.number().optional(), + operatingCashFlow: z.number().optional(), + debtToAsset: z.number().optional() +}); + +const fundamentalsSchema = z.array(financialStatementSchema).min(1); + +const relatedDataSchema = z.object({ + sector: z.string().min(1).optional(), + industryRank: z.number().optional(), + beta: z.number().optional(), + avgVolume20: z.number().optional(), + volumeRatio: z.number().optional(), + turnoverRate: z.number().optional(), + volatility20: z.number().optional(), + drawdownFromHigh: z.number().optional(), + relativeStrength60: z.number().optional(), + support: z.number().optional(), + resistance: z.number().optional(), + capitalFlow: z.number().optional(), + sentimentScore: z.number().optional(), + newsHeat: z.number().optional(), + marketCap: z.number().optional(), + enterpriseValue: z.number().optional(), + sharesOutstanding: z.number().optional(), + freeFloat: z.number().optional(), + priceToBook: z.number().optional(), + priceToSales: z.number().optional(), + pegRatio: z.number().optional(), + evToEbitda: z.number().optional(), + dividendYield: z.number().optional(), + freeCashFlowYield: z.number().optional(), + grossMarginTtm: z.number().optional(), + netMarginTtm: z.number().optional(), + roeTtm: z.number().optional(), + roaTtm: z.number().optional(), + debtToEquity: z.number().optional(), + currentRatio: z.number().optional(), + revenueCagr3y: z.number().optional(), + epsGrowthTtm: z.number().optional(), + analystRating: z.string().optional(), + analystTargetPrice: z.number().optional(), + institutionalOwnership: z.number().optional(), + insiderOwnership: z.number().optional(), + shortInterestRatio: z.number().optional(), + nextExDividendDate: z.string().optional(), + indexMembership: z.array(z.string()).default([]), + recentNews: z.array(z.object({ title: z.string(), url: z.string(), source: z.string().optional(), publishedAt: z.string().optional() })).default([]), + socialMentions: z.array(z.object({ title: z.string(), url: z.string(), source: z.string().optional(), publishedAt: z.string().optional() })).default([]), + socialDiscussionCount: z.number().optional(), + webSentimentLabel: z.string().optional(), + riskFlags: z.array(z.string()).default([]), + asOf: z.string().optional(), + sources: z.array(z.object({ name: z.string(), url: z.string().optional(), asOf: z.string().optional() })).default([]) +}); + +const quantAnalysisSchema = z.object({ + action: z.enum(["buy_watch", "hold", "avoid"]), + score: z.number().min(0).max(100), + confidence: z.number().min(0).max(1), + horizon: z.string().min(1), + summary: z.string().min(1), + factors: z + .array( + z.object({ + name: z.string().min(1), + score: z.number().min(0).max(100), + weight: z.number().min(0).max(1), + evidence: z.string().min(1) + }) + ) + .min(3), + tradePlan: z + .object({ + direction: z.enum(["long", "wait", "reduce", "avoid"]), + entryPrice: z.number().optional(), + targetPrice: z.number().optional(), + stopLoss: z.number().optional(), + expectedReturnPct: z.number().optional(), + invalidation: z.string().optional(), + rationale: z.string().min(1) + }) + .optional(), + marketContext: z.array(z.string()).default([]), + riskNotes: z.array(z.string()).default([]) }); const responsesOutputSchema = z.object({ @@ -44,6 +168,41 @@ export class IpadsResponsesProvider implements MarketDataProvider { ) {} async snapshot(stock: WatchStock): Promise { + if (stock.market === "CN") { + return fetchSinaChinaSnapshot(stock, this.fetchImpl); + } + return fetchNasdaqSnapshot(stock, this.fetchImpl); + } + + async candles(stock: WatchStock, startDate?: string): Promise { + if (stock.market === "CN") { + return fetchSinaChinaCandles(stock, this.fetchImpl, startDate); + } + return fetchNasdaqCandles(stock, this.fetchImpl, startDate); + } + + async fundamentals(stock: WatchStock): Promise { + return this.requestJson(buildFundamentalsPrompt(stock), fundamentalsSchema, "fundamentals").then((statements) => + statements.sort((left, right) => right.period.localeCompare(left.period)) + ); + } + + async relatedData(stock: WatchStock): Promise { + if (stock.market === "CN") { + return fetchSinaChinaRelatedData(stock, this.fetchImpl); + } + return fetchNasdaqRelatedData(stock, this.fetchImpl); + } + + async quantAnalysis(input: QuantAnalysisInput): Promise { + try { + return await this.requestJson(buildQuantAnalysisPrompt(input), quantAnalysisSchema, "quant analysis"); + } catch (error) { + return buildRuleBasedQuantAnalysis(input, error instanceof Error ? error.message : "远端 agent 未返回有效结果"); + } + } + + private async requestJson(prompt: string, schema: z.ZodType, context: string): Promise { const response = await this.fetchImpl(`${IPADS_BASE_URL}/${IPADS_WIRE_API}`, { method: "POST", headers: { @@ -51,9 +210,10 @@ export class IpadsResponsesProvider implements MarketDataProvider { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(60_000), body: JSON.stringify({ model: IPADS_MODEL, - input: buildSnapshotPrompt(stock) + input: prompt }) }); @@ -61,20 +221,67 @@ export class IpadsResponsesProvider implements MarketDataProvider { throw new Error(`IPADS Responses request failed: ${response.status}`); } - return parseResponsesSnapshot(await response.json()); + return parseResponsesJson(await response.json(), schema, context); } } function buildSnapshotPrompt(stock: WatchStock): string { return [ - "Return only a valid JSON object for a stock monitoring snapshot.", + "Return only a valid JSON object for a stock monitoring snapshot using verified current market, filing, exchange, or reputable financial data.", "Use this exact schema:", - '{"price": 100, "currency": "USD", "pe": 20, "forwardPe": 18, "revenueGrowth": 0.08, "profitGrowth": 0.05, "nextEarningsDate": "2026-05-13", "peers": [{"display": "MSFT", "pe": 28}], "news": [{"title": "Earnings preview", "url": "https://example.com/news"}]}', - "All numeric fields must be numbers, not null. If precise market data is unavailable, provide a conservative approximate monitoring snapshot and keep news empty.", + '{"price": 100, "currency": "USD", "pe": 20, "forwardPe": 18, "revenueGrowth": 0.08, "profitGrowth": 0.05, "nextEarningsDate": "2026-05-13", "peers": [{"display": "MSFT", "pe": 28}], "news": [{"title": "Earnings preview", "url": "https://example.com/news"}], "asOf": "2026-05-12", "sources": [{"name": "exchange or data vendor", "url": "https://example.com", "asOf": "2026-05-12"}]}', + "Do not infer, fabricate, or fill with approximate values. Required numeric fields must be the latest verified values available from sources. Optional fields may be omitted when not verified.", `Stock request: ${JSON.stringify({ symbol: stock.display, market: stock.market, name: stock.name })}` ].join("\n"); } +function buildFundamentalsPrompt(stock: WatchStock): string { + return [ + "Return only a valid JSON array of recent financial statements for this stock using verified company filings or reputable financial data providers.", + "Use the latest 8 quarterly or semiannual periods available, sorted newest to oldest.", + "Use this exact item schema:", + '{"period":"2026Q1","reportDate":"2026-04-30","revenue":1000000000,"netIncome":120000000,"eps":1.2,"grossMargin":0.42,"operatingCashFlow":150000000,"debtToAsset":0.34}', + "Do not infer, fabricate, annualize, or estimate fields. Optional fields may be omitted when not verified.", + `Stock request: ${JSON.stringify({ symbol: stock.display, market: stock.market, name: stock.name })}` + ].join("\n"); +} + +function buildRelatedDataPrompt(stock: WatchStock): string { + return [ + "Return only a valid JSON object of verified market, valuation, quality, liquidity, ownership, analyst, and risk context for quantitative stock analysis.", + "Use this exact schema:", + '{"sector":"Technology","industryRank":12,"beta":1.1,"avgVolume20":12000000,"volumeRatio":1.2,"turnoverRate":0.018,"volatility20":0.026,"drawdownFromHigh":0.12,"relativeStrength60":0.08,"support":98,"resistance":115,"capitalFlow":0.03,"sentimentScore":0.62,"newsHeat":0.55,"marketCap":10000000000,"enterpriseValue":11000000000,"sharesOutstanding":1000000000,"freeFloat":0.82,"priceToBook":4.2,"priceToSales":5.1,"pegRatio":1.6,"evToEbitda":18,"dividendYield":0.012,"freeCashFlowYield":0.025,"grossMarginTtm":0.42,"netMarginTtm":0.18,"roeTtm":0.22,"roaTtm":0.11,"debtToEquity":0.35,"currentRatio":1.8,"revenueCagr3y":0.1,"epsGrowthTtm":0.08,"analystRating":"Buy","analystTargetPrice":115,"institutionalOwnership":0.61,"insiderOwnership":0.04,"shortInterestRatio":1.8,"nextExDividendDate":"2026-06-10","indexMembership":["S&P 500"],"riskFlags":["high valuation"],"asOf":"2026-05-12","sources":[{"name":"verified data source","url":"https://example.com","asOf":"2026-05-12"}]}', + "All ratios are decimals, for example 0.12 for 12%. capitalFlow is -1 to 1 if verified. sentimentScore and newsHeat are 0 to 1 only if derived from verified data.", + "Do not infer, fabricate, or approximate. Omit optional fields when not verified. Always include riskFlags and sources arrays, even if empty.", + `Stock request: ${JSON.stringify({ symbol: stock.display, market: stock.market, name: stock.name })}` + ].join("\n"); +} + +function buildQuantAnalysisPrompt(input: QuantAnalysisInput): string { + const compactInput = { + stock: input.stock, + snapshot: input.snapshot, + latestCandles: input.candles.slice(-90), + latestIndicators: input.indicators.slice(-90), + fundamentals: input.fundamentals.slice(0, 8), + relatedData: input.relatedData + }; + return [ + "只返回一个合法 JSON 对象,用中文做标准量化监控分析。这不是个性化投资建议。", + "判断当前数据下这只股票是否值得短期买入观察、继续持有观察,或回避/减仓。", + "必须使用动量、趋势、波动率、流动性、估值、成长质量、资金/舆情、新闻和社交媒体讨论、风险控制进行综合判断。", + "如果输入里有 recentNews、socialMentions、webSentimentLabel、socialDiscussionCount,必须把它们作为市场舆情证据;如果缺失,要在 riskNotes 里说明。", + "不要捏造缺失数据。只能基于提供的已验证输入结论;缺失或过期数据必须降低 confidence 或写入 riskNotes。", + "所有 summary、factor evidence、riskNotes、marketContext、tradePlan.rationale 和 invalidation 都必须是中文。", + "使用这个精确 schema:", + '{"action":"buy_watch","score":72,"confidence":0.68,"horizon":"1-3个月","summary":"趋势和成交量改善,但波动较高,适合小仓位分批观察。","factors":[{"name":"趋势动量","score":75,"weight":0.2,"evidence":"收盘价站上MA20和MA60。"}],"tradePlan":{"direction":"long","entryPrice":18.6,"targetPrice":22.3,"stopLoss":17.2,"expectedReturnPct":0.1989,"invalidation":"跌破17.2且成交量放大。","rationale":"目标价取20日压力位、分析师目标价和波动区间的保守值。"},"marketContext":["近7天新闻热度有限,社交讨论不足。"],"riskNotes":["波动率较高,需控制仓位。"]}', + "action 必须是 buy_watch、hold、avoid 之一。score 0-100。confidence 0-1。factor weights 加总应接近 1。", + "如果 action=buy_watch,tradePlan 必须给出 entryPrice、targetPrice、stopLoss、expectedReturnPct 和开始卖出的价格依据。", + "如果 action=avoid,tradePlan.direction 应为 avoid 或 reduce,并给出短期回避/卖出触发价或风险价位。hold 时 direction 用 wait。", + `Analysis input: ${JSON.stringify(compactInput)}` + ].join("\n"); +} + export class DemoMarketDataProvider implements MarketDataProvider { async snapshot(stock: WatchStock): Promise { const seed = stock.display.split("").reduce((sum, char) => sum + char.charCodeAt(0), 0); @@ -100,9 +307,136 @@ export class DemoMarketDataProvider implements MarketDataProvider { title: `${stock.display} related sector signal updated`, url: `https://example.local/news/${encodeURIComponent(stock.display)}` } - ] + ], + asOf: new Date().toISOString().slice(0, 10), + sources: [{ name: "demo provider", asOf: new Date().toISOString().slice(0, 10) }] }; } + + async candles(stock: WatchStock, startDate?: string): Promise { + const seed = symbolSeed(stock); + const candles: PriceCandle[] = []; + const end = new Date(); + const start = startDate ? new Date(`${startDate}T00:00:00.000Z`) : new Date(Date.UTC(end.getUTCFullYear() - 10, end.getUTCMonth(), end.getUTCDate())); + let close = 26 + (seed % 160); + + for (let index = 0; candles.length < (startDate ? 12 : 2520); index += 1) { + const date = new Date(start.getTime() + index * 24 * 60 * 60 * 1000); + const day = date.getUTCDay(); + if (day === 0 || day === 6) { + continue; + } + + const wave = Math.sin((index + seed) / 7) * 1.9 + Math.cos((index + seed) / 19) * 1.1; + const drift = ((seed % 11) - 4) * 0.015; + const open = close + Math.sin((index + seed) / 5) * 0.9; + close = Math.max(2, open + wave * 0.55 + drift); + const high = Math.max(open, close) + 0.7 + Math.abs(Math.sin(index / 3)) * 1.4; + const low = Math.max(1, Math.min(open, close) - 0.7 - Math.abs(Math.cos(index / 4)) * 1.2); + const volumeBase = 1_200_000 + (seed % 80) * 45_000; + const volume = Math.round(volumeBase * (1 + Math.abs(wave) / 4 + (index % 9) / 18)); + + candles.push({ + date: date.toISOString().slice(0, 10), + open: round(open, 2), + high: round(high, 2), + low: round(low, 2), + close: round(close, 2), + volume + }); + } + + return candles; + } + + async fundamentals(stock: WatchStock): Promise { + const seed = symbolSeed(stock); + const baseRevenue = 1_000_000_000 + (seed % 900) * 4_500_000; + return Array.from({ length: 8 }, (_, index) => { + const quarter = 1 + ((4 - (index % 4)) % 4); + const year = 2026 - Math.floor((index + (quarter === 4 ? 0 : 1)) / 4); + const growth = 1 - index * 0.035 + Math.sin((seed + index) / 3) * 0.025; + const margin = 0.12 + (seed % 18) / 100 + Math.sin(index / 2) * 0.018; + const revenue = Math.round(baseRevenue * growth); + const netIncome = Math.round(revenue * margin); + + return { + period: `${year}Q${quarter}`, + reportDate: `${year}-${String(quarter * 3).padStart(2, "0")}-28`, + revenue, + netIncome, + eps: round(netIncome / (650_000_000 + (seed % 40) * 10_000_000), 2), + grossMargin: round(0.34 + (seed % 16) / 100 + Math.sin(index) * 0.015, 4), + operatingCashFlow: Math.round(netIncome * (1.05 + (index % 3) * 0.08)), + debtToAsset: round(0.24 + (seed % 24) / 100, 4) + }; + }); + } + + async relatedData(stock: WatchStock): Promise { + const seed = symbolSeed(stock); + const snapshot = await this.snapshot(stock); + const candles = await this.candles(stock); + const recent = candles.slice(-60); + const high60 = Math.max(...recent.map((candle) => candle.high)); + const low20 = Math.min(...recent.slice(-20).map((candle) => candle.low)); + const high20 = Math.max(...recent.slice(-20).map((candle) => candle.high)); + const last = candles[candles.length - 1]; + const first60 = recent[0]; + const avgVolume20 = Math.round(candles.slice(-20).reduce((sum, candle) => sum + candle.volume, 0) / 20); + + return { + sector: stock.market === "US" ? "Technology" : stock.market === "HK" ? "Internet & Consumer" : "Large-cap Consumption", + industryRank: 1 + (seed % 30), + beta: round(0.75 + (seed % 70) / 100, 2), + avgVolume20, + volumeRatio: round(last.volume / avgVolume20, 2), + turnoverRate: round(0.006 + (seed % 34) / 1000, 4), + volatility20: round(0.018 + (seed % 28) / 1000, 4), + drawdownFromHigh: round((high60 - last.close) / high60, 4), + relativeStrength60: round((last.close - first60.close) / first60.close - ((seed % 9) - 3) / 100, 4), + support: round(low20, 2), + resistance: round(high20, 2), + capitalFlow: round(((seed % 41) - 20) / 100, 4), + sentimentScore: round(0.42 + (seed % 36) / 100, 4), + newsHeat: round(snapshot.news.length ? 0.52 + (seed % 24) / 100 : 0.22 + (seed % 22) / 100, 4), + riskFlags: seed % 5 === 0 ? ["成交量放大但趋势未确认"] : seed % 7 === 0 ? ["估值波动需要等待财报确认"] : [], + marketCap: Math.round(snapshot.price * (700_000_000 + (seed % 80) * 12_000_000)), + enterpriseValue: Math.round(snapshot.price * (760_000_000 + (seed % 95) * 14_000_000)), + sharesOutstanding: 700_000_000 + (seed % 80) * 12_000_000, + freeFloat: round(0.55 + (seed % 35) / 100, 4), + priceToBook: round(1.2 + (seed % 42) / 10, 2), + priceToSales: round(0.8 + (seed % 50) / 10, 2), + pegRatio: round(0.7 + (seed % 32) / 10, 2), + evToEbitda: round(7 + (seed % 28), 2), + dividendYield: round((seed % 45) / 1000, 4), + freeCashFlowYield: round(0.015 + (seed % 50) / 1000, 4), + grossMarginTtm: round(0.32 + (seed % 28) / 100, 4), + netMarginTtm: round(0.08 + (seed % 20) / 100, 4), + roeTtm: round(0.06 + (seed % 25) / 100, 4), + roaTtm: round(0.03 + (seed % 13) / 100, 4), + debtToEquity: round(0.15 + (seed % 80) / 100, 4), + currentRatio: round(0.9 + (seed % 22) / 10, 2), + revenueCagr3y: round(((seed % 24) - 4) / 100, 4), + epsGrowthTtm: round(((seed % 30) - 8) / 100, 4), + analystRating: seed % 3 === 0 ? "Buy" : seed % 3 === 1 ? "Hold" : "Neutral", + analystTargetPrice: round(last.close * (1.04 + (seed % 18) / 100), 2), + institutionalOwnership: round(0.22 + (seed % 65) / 100, 4), + insiderOwnership: round((seed % 18) / 100, 4), + shortInterestRatio: round(0.5 + (seed % 45) / 10, 2), + indexMembership: stock.market === "US" ? ["Demo large-cap universe"] : [], + recentNews: snapshot.news, + socialMentions: [], + socialDiscussionCount: 0, + webSentimentLabel: snapshot.news.length ? "中性" : undefined, + asOf: new Date().toISOString().slice(0, 10), + sources: [{ name: "demo provider", asOf: new Date().toISOString().slice(0, 10) }] + }; + } + + async quantAnalysis(input: QuantAnalysisInput): Promise { + return buildRuleBasedQuantAnalysis(input); + } } export function createProvider(apiKey: string): MarketDataProvider { @@ -112,13 +446,760 @@ export function createProvider(apiKey: string): MarketDataProvider { return new DemoMarketDataProvider(); } -function parseResponsesSnapshot(payload: unknown): StockSnapshot { +function parseResponsesJson(payload: unknown, schema: z.ZodType, context: string): T { const response = responsesOutputSchema.parse(payload); const text = response.output_text ?? response.output?.flatMap((item) => item.content ?? []).find((content) => content.text)?.text; if (!text) { - throw new Error("IPADS Responses payload did not include output text"); + throw new Error(`IPADS Responses payload did not include output text for ${context}`); } - return snapshotSchema.parse(JSON.parse(stripJsonFence(text))); + return schema.parse(JSON.parse(stripJsonFence(text))); +} + +function buildRuleBasedQuantAnalysis(input: QuantAnalysisInput, fallbackReason?: string): QuantAnalysis { + const fallbackPrice = positiveNumber(input.snapshot.price); + const last = input.candles[input.candles.length - 1] ?? { + date: "", + open: fallbackPrice, + high: fallbackPrice, + low: fallbackPrice, + close: fallbackPrice, + volume: 0 + }; + const latest = input.indicators[input.indicators.length - 1] ?? { date: last.date }; + const previous = input.candles[Math.max(0, input.candles.length - 21)] ?? last; + const volumeRatio = input.relatedData.volumeRatio ?? 1; + const volatility20 = input.relatedData.volatility20 ?? estimateVolatility(input.candles.slice(-21)) ?? 0.03; + const sentimentScore = input.relatedData.sentimentScore ?? scoreSentimentLabel(input.relatedData.webSentimentLabel); + const capitalFlow = input.relatedData.capitalFlow ?? 0; + const relativeStrength60 = input.relatedData.relativeStrength60 ?? 0; + const drawdownFromHigh = input.relatedData.drawdownFromHigh ?? 0.15; + const newsHeat = input.relatedData.newsHeat ?? Math.min(1, ((input.relatedData.recentNews?.length ?? 0) + (input.relatedData.socialMentions?.length ?? 0)) / 12); + const recentReturn = safeReturn(last.close, previous.close); + const momentum = clampScore(50 + (recentReturn ?? 0) * 220 + (latest.ma20 && last.close > latest.ma20 ? 12 : input.candles.length ? -8 : 0)); + const pe = input.snapshot.pe; + const valuation = isFiniteNumber(pe) + ? clampScore(62 - Math.max(0, pe - 18) * 1.4 + (input.snapshot.forwardPe && input.snapshot.forwardPe < pe ? 8 : 0)) + : input.relatedData.analystTargetPrice && last.close > 0 + ? clampScore(50 + (safeReturn(input.relatedData.analystTargetPrice, last.close, 0) ?? 0) * 80) + : 45; + const growth = clampScore(50 + (input.snapshot.revenueGrowth ?? 0) * 260 + (input.snapshot.profitGrowth ?? 0) * 320); + const liquidity = clampScore(55 + (volumeRatio - 1) * 18 - volatility20 * 220); + const sentiment = clampScore(40 + sentimentScore * 35 + capitalFlow * 18 + relativeStrength60 * 120 + newsHeat * 8); + const riskPenalty = drawdownFromHigh * 70 + input.relatedData.riskFlags.length * 6 + (input.candles.length < 252 ? 7 : 0); + const score = clampScore(momentum * 0.24 + valuation * 0.2 + growth * 0.22 + liquidity * 0.16 + sentiment * 0.18 - riskPenalty); + const action = score >= 68 ? "buy_watch" : score <= 42 ? "avoid" : "hold"; + const tradePlan = buildTradePlan(action, last.close, input.relatedData, volatility20); + const marketContext = [ + input.relatedData.webSentimentLabel ? `网页/社交舆情标签:${input.relatedData.webSentimentLabel}` : "网页/社交舆情数据不足,未作为强信号。", + `近端新闻 ${input.relatedData.recentNews?.length ?? 0} 条,社交讨论 ${input.relatedData.socialDiscussionCount ?? input.relatedData.socialMentions?.length ?? 0} 条。`, + input.relatedData.analystTargetPrice ? `分析师目标价 ${input.relatedData.analystTargetPrice}。` : "缺少可验证分析师目标价。" + ]; + + return { + action, + score: round(score, 0), + confidence: round(Math.min(0.86, 0.48 + Math.abs(score - 50) / 100 - (fallbackReason ? 0.08 : 0)), 2), + horizon: "1-3个月", + summary: + action === "buy_watch" + ? `趋势、估值/目标价或舆情组合达到买入观察阈值;参考卖出价 ${formatPrice(tradePlan.targetPrice)},预估收益 ${formatPct(tradePlan.expectedReturnPct)}。` + : action === "avoid" + ? `量化信号偏弱或风险惩罚较高,短期应回避/降低仓位;跌破 ${formatPrice(tradePlan.stopLoss)} 后风险进一步放大。` + : `优势信号不足,维持观察;只有重新站上 ${formatPrice(tradePlan.targetPrice)} 附近并放量才考虑提高权重。`, + factors: [ + { name: "趋势动量", score: round(momentum, 0), weight: 0.24, evidence: latest.ma20 ? `收盘价${last.close > latest.ma20 ? "高于" : "低于"}MA20,60日相对强度 ${formatPct(relativeStrength60)}。` : "均线数据不足。" }, + { name: "估值与目标价", score: round(valuation, 0), weight: 0.2, evidence: `PE ${input.snapshot.pe ?? "-"},Forward PE ${input.snapshot.forwardPe ?? "-"},分析师目标价 ${input.relatedData.analystTargetPrice ?? "-"}。` }, + { name: "成长质量", score: round(growth, 0), weight: 0.22, evidence: `收入增速 ${formatPct(input.snapshot.revenueGrowth)},利润增速 ${formatPct(input.snapshot.profitGrowth)},财报期数 ${input.fundamentals.length}。` }, + { name: "流动性与波动", score: round(liquidity, 0), weight: 0.16, evidence: `量比 ${volumeRatio},20日波动 ${formatPct(volatility20)}。` }, + { name: "资金与舆情", score: round(sentiment, 0), weight: 0.18, evidence: `资金流 ${capitalFlow},舆情分 ${sentimentScore},新闻热度 ${newsHeat}。` } + ], + tradePlan, + marketContext, + riskNotes: [ + ...(fallbackReason ? [`远端 agent 未返回有效分析,已使用本地量化规则兜底:${fallbackReason}`] : []), + input.candles.length === 0 + ? "数据源未返回历史K线,技术面已忽略。" + : input.candles.length < 2000 + ? `K线真实历史只有 ${input.candles.length} 根,可能是上市时间较短或数据源覆盖有限。` + : "", + input.fundamentals.length ? "" : "缺少可验证财报数据,基本面权重已降低。", + `距离60日高点回撤 ${formatPct(drawdownFromHigh)}。`, + ...input.relatedData.riskFlags, + "该结论是量化监控输出,不构成个性化投资建议。" + ].filter(Boolean) + }; +} + +function buildTradePlan(action: QuantAnalysis["action"], close: number, relatedData: RelatedStockData, volatility20: number): NonNullable { + if (!isFiniteNumber(close) || close <= 0) { + return { + direction: action === "avoid" ? "avoid" : "wait", + invalidation: "数据源未返回有效价格,无法生成价格触发条件。", + rationale: "当前缺少有效价格和历史K线,交易计划只保留观察结论;需要等待行情源恢复后再计算参考买入、卖出和止损价。" + }; + } + const support = relatedData.support && relatedData.support > 0 ? relatedData.support : close * (1 - Math.max(0.04, volatility20 * 2.2)); + const resistance = relatedData.resistance && relatedData.resistance > close ? relatedData.resistance : close * (1 + Math.max(0.06, volatility20 * 2.8)); + const analystTarget = relatedData.analystTargetPrice && relatedData.analystTargetPrice > close ? relatedData.analystTargetPrice : undefined; + const targetPrice = round(Math.max(resistance, analystTarget ? Math.min(analystTarget, close * 1.35) : resistance), 2); + const stopLoss = round(Math.min(support, close * (1 - Math.max(0.035, volatility20 * 1.7))), 2); + const expectedReturnPct = round((targetPrice - close) / close, 4); + if (action === "buy_watch") { + return { + direction: "long", + entryPrice: round(close, 2), + targetPrice, + stopLoss, + expectedReturnPct, + invalidation: `跌破 ${stopLoss} 且无法快速收回,或放量跌破关键支撑。`, + rationale: `买入后优先参考 ${targetPrice} 附近开始分批止盈;该价位综合了近期压力位、分析师目标价和当前波动区间。` + }; + } + if (action === "avoid") { + return { + direction: "avoid", + entryPrice: round(close, 2), + targetPrice: round(resistance, 2), + stopLoss, + expectedReturnPct, + invalidation: `重新站上 ${round(resistance, 2)} 且量能改善前不提高仓位。`, + rationale: `当前风险收益比不足,短期更适合等待趋势修复;已有仓位可把 ${stopLoss} 作为风险控制参考。` + }; + } + return { + direction: "wait", + entryPrice: round(close, 2), + targetPrice: round(resistance, 2), + stopLoss, + expectedReturnPct: round((resistance - close) / close, 4), + invalidation: `跌破 ${stopLoss} 表示观察假设失效。`, + rationale: `当前不是明确买点,等待突破 ${round(resistance, 2)} 或回踩支撑企稳后再评估。` + }; +} + +function estimateVolatility(candles: PriceCandle[]): number | undefined { + if (candles.length < 3) { + return undefined; + } + const returns = candles.slice(1).map((candle, index) => (candle.close - candles[index].close) / candles[index].close); + const average = returns.reduce((sum, value) => sum + value, 0) / returns.length; + const variance = returns.reduce((sum, value) => sum + (value - average) ** 2, 0) / returns.length; + return round(Math.sqrt(variance), 4); +} + +function safeReturn(current: number | undefined, previous: number | undefined, fallback?: number): number | undefined { + if (!isFiniteNumber(current) || !isFiniteNumber(previous) || previous <= 0) { + return fallback; + } + return (current - previous) / previous; +} + +function positiveNumber(value: number | undefined): number { + return isFiniteNumber(value) && value > 0 ? value : 0; +} + +function scoreSentimentLabel(label: string | undefined): number { + if (!label) { + return 0.5; + } + const normalized = label.toLowerCase(); + if (normalized.includes("bull") || normalized.includes("正") || normalized.includes("积极")) { + return 0.68; + } + if (normalized.includes("bear") || normalized.includes("负") || normalized.includes("谨慎")) { + return 0.35; + } + return 0.5; +} + +async function fetchNasdaqCandles(stock: WatchStock, fetchImpl: FetchLike, startDate?: string): Promise { + if (stock.market !== "US") { + throw new Error(`Nasdaq historical candle source currently supports US symbols only: ${stock.display}`); + } + const to = new Date().toISOString().slice(0, 10); + const from = startDate ?? new Date(Date.UTC(new Date().getUTCFullYear() - 10, new Date().getUTCMonth(), new Date().getUTCDate())).toISOString().slice(0, 10); + for (const assetClass of NASDAQ_ASSET_CLASSES) { + const payload = nasdaqHistoricalSchema.parse( + await fetchNasdaqJson(fetchImpl, `https://api.nasdaq.com/api/quote/${encodeURIComponent(stock.symbol)}/historical?assetclass=${assetClass}&fromdate=${from}&todate=${to}&limit=9999`) + ); + const rows = payload.data?.tradesTable?.rows ?? []; + const candles = rows.map(parseNasdaqRow).filter((candle): candle is PriceCandle => candle !== undefined); + if (candles.length) { + return candles.sort((left, right) => left.date.localeCompare(right.date)); + } + } + return []; +} + +async function fetchNasdaqSnapshot(stock: WatchStock, fetchImpl: FetchLike): Promise { + if (stock.market !== "US") { + throw new Error(`Nasdaq snapshot source currently supports US symbols only: ${stock.display}`); + } + for (const assetClass of NASDAQ_ASSET_CLASSES) { + const [info, summary] = await Promise.all([ + fetchNasdaqJson(fetchImpl, `https://api.nasdaq.com/api/quote/${encodeURIComponent(stock.symbol)}/info?assetclass=${assetClass}`), + fetchNasdaqJson(fetchImpl, `https://api.nasdaq.com/api/quote/${encodeURIComponent(stock.symbol)}/summary?assetclass=${assetClass}`) + ]); + const parsedInfo = nasdaqInfoSchema.parse(info); + const parsedSummary = nasdaqSummarySchema.parse(summary); + const price = parseNasdaqNumber(parsedInfo.data?.primaryData?.lastSalePrice ?? ""); + if (!isFiniteNumber(price) || price <= 0) { + continue; + } + const path = assetClass === "etf" ? "etf" : "stocks"; + return { + price, + currency: "USD", + forwardPe: undefined, + peers: [], + news: (parsedInfo.data?.notifications ?? []).slice(0, 5).map((notification) => ({ + title: notification.headline, + url: `https://www.nasdaq.com/market-activity/${path}/` + encodeURIComponent(stock.symbol.toLowerCase()), + source: "Nasdaq" + })), + asOf: parsedInfo.data?.primaryData?.lastTradeTimestamp ?? new Date().toISOString(), + sources: [ + { name: `Nasdaq ${assetClass} quote info`, url: "https://api.nasdaq.com/api/quote/info", asOf: parsedInfo.data?.primaryData?.lastTradeTimestamp }, + { name: `Nasdaq ${assetClass} summary`, url: "https://api.nasdaq.com/api/quote/summary", asOf: new Date().toISOString().slice(0, 10) } + ] + }; + } + throw new Error(`Nasdaq snapshot did not include a valid positive price for ${stock.display}`); +} + +async function fetchNasdaqRelatedData(stock: WatchStock, fetchImpl: FetchLike): Promise { + if (stock.market !== "US") { + throw new Error(`Nasdaq research data source currently supports US symbols only: ${stock.display}`); + } + const summaryPayload = await fetchNasdaqSummaryWithAssetClass(stock, fetchImpl); + const [summary, analyst, holdings, recentNews, stocktwitsMentions, redditMentions] = await Promise.all([ + Promise.resolve(summaryPayload.payload), + fetchNasdaqJson(fetchImpl, `https://api.nasdaq.com/api/analyst/${encodeURIComponent(stock.symbol)}/targetprice`).catch(() => undefined), + fetchNasdaqJson(fetchImpl, `https://api.nasdaq.com/api/company/${encodeURIComponent(stock.symbol)}/institutional-holdings`).catch(() => undefined), + fetchYahooFinanceNews(stock, fetchImpl).catch(() => []), + fetchStocktwitsMentions(stock, fetchImpl).catch(() => []), + fetchRedditMentions(stock, fetchImpl).catch(() => []) + ]); + const summaryData = nasdaqSummarySchema.parse(summary).data?.summaryData ?? {}; + const analystData = analyst ? nasdaqAnalystSchema.parse(analyst).data?.consensusOverview : undefined; + const holdingsData = holdings ? nasdaqHoldingsSchema.parse(holdings).data?.ownershipSummary : undefined; + const highLow = parseRange(summaryData.FiftTwoWeekHighLow?.value); + const todayRange = parseRange(summaryData.TodayHighLow?.value); + const targetPrice = parseOptionalNumber(summaryData.OneYrTarget?.value) ?? analystData?.priceTarget; + const shareVolume = parseOptionalNumber(summaryData.ShareVolume?.value); + const avgVolume = parseOptionalNumber(summaryData.AverageVolume?.value); + + const socialMentions = [...stocktwitsMentions, ...redditMentions].slice(0, 12); + const webSentimentLabel = inferWebSentimentLabel(stocktwitsMentions, recentNews); + + return { + sector: stringifyOptional(summaryData.Sector?.value), + avgVolume20: avgVolume, + volumeRatio: shareVolume && avgVolume ? round(shareVolume / avgVolume, 4) : undefined, + support: todayRange?.low, + resistance: todayRange?.high, + marketCap: parseOptionalNumber(summaryData.MarketCap?.value), + analystRating: analystConsensusLabel(analystData), + analystTargetPrice: targetPrice, + institutionalOwnership: parsePercent(holdingsData?.SharesOutstandingPCT?.value), + sharesOutstanding: parseSharesOutstanding(holdingsData?.ShareoutstandingTotal?.value), + drawdownFromHigh: calculateDrawdown(highLow?.high, parseOptionalNumber(summaryData.PreviousClose?.value)), + recentNews: recentNews.slice(0, 10), + socialMentions, + socialDiscussionCount: socialMentions.length, + webSentimentLabel, + riskFlags: [], + sources: [ + { name: `Nasdaq ${summaryPayload.assetClass} summary`, url: "https://api.nasdaq.com/api/quote/summary", asOf: new Date().toISOString().slice(0, 10) }, + { name: "Nasdaq analyst targetprice", url: "https://api.nasdaq.com/api/analyst/targetprice", asOf: new Date().toISOString().slice(0, 10) }, + { name: "Nasdaq institutional holdings", url: "https://api.nasdaq.com/api/company/institutional-holdings", asOf: new Date().toISOString().slice(0, 10) }, + { name: "Yahoo Finance RSS", url: "https://feeds.finance.yahoo.com/rss/2.0/headline", asOf: new Date().toISOString().slice(0, 10) }, + { name: "Stocktwits public stream", url: "https://api.stocktwits.com/api/2/streams/symbol", asOf: new Date().toISOString().slice(0, 10) }, + { name: "Reddit public search", url: "https://www.reddit.com/search.json", asOf: new Date().toISOString().slice(0, 10) } + ], + asOf: new Date().toISOString().slice(0, 10) + }; +} + +async function fetchNasdaqSummaryWithAssetClass(stock: WatchStock, fetchImpl: FetchLike): Promise<{ assetClass: NasdaqAssetClass; payload: unknown }> { + let fallback: { assetClass: NasdaqAssetClass; payload: unknown } | undefined; + for (const assetClass of NASDAQ_ASSET_CLASSES) { + const payload = await fetchNasdaqJson(fetchImpl, `https://api.nasdaq.com/api/quote/${encodeURIComponent(stock.symbol)}/summary?assetclass=${assetClass}`); + const summaryData = nasdaqSummarySchema.parse(payload).data?.summaryData ?? {}; + fallback ??= { assetClass, payload }; + if (Object.keys(summaryData).length) { + return { assetClass, payload }; + } + } + return fallback ?? { assetClass: "stocks", payload: {} }; +} + +async function fetchSinaChinaSnapshot(stock: WatchStock, fetchImpl: FetchLike): Promise { + const quote = await fetchSinaChinaQuote(stock, fetchImpl); + return { + price: quote.price, + currency: "CNY", + peers: [], + news: [], + asOf: quote.asOf, + sources: [{ name: "Sina CN quote", url: "https://hq.sinajs.cn/list=", asOf: quote.asOf }] + }; +} + +async function fetchSinaChinaCandles(stock: WatchStock, fetchImpl: FetchLike, startDate?: string): Promise { + const symbol = sinaChinaSymbol(stock); + const text = await fetchText( + fetchImpl, + `https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData?symbol=${symbol}&scale=240&ma=no&datalen=2600` + ); + const rows = JSON.parse(text) as SinaKlineRow[] | null; + const start = startDate ? new Date(`${startDate}T00:00:00.000Z`).getTime() : Date.UTC(new Date().getUTCFullYear() - 10, new Date().getUTCMonth(), new Date().getUTCDate()); + return (rows ?? []) + .map(parseSinaKlineRow) + .filter((candle): candle is PriceCandle => candle !== undefined) + .filter((candle) => new Date(`${candle.date}T00:00:00.000Z`).getTime() >= start) + .sort((left, right) => left.date.localeCompare(right.date)); +} + +async function fetchSinaChinaRelatedData(stock: WatchStock, fetchImpl: FetchLike): Promise { + const [quote, candles] = await Promise.all([ + fetchSinaChinaQuote(stock, fetchImpl), + fetchSinaChinaCandles(stock, fetchImpl).catch(() => []) + ]); + const recent = candles.slice(-60); + const last = candles[candles.length - 1]; + const first60 = recent[0]; + const high60 = recent.length ? Math.max(...recent.map((candle) => candle.high)) : undefined; + const low20 = candles.length ? Math.min(...candles.slice(-20).map((candle) => candle.low)) : undefined; + const high20 = candles.length ? Math.max(...candles.slice(-20).map((candle) => candle.high)) : undefined; + const avgVolume20 = candles.length ? Math.round(candles.slice(-20).reduce((sum, candle) => sum + candle.volume, 0) / Math.min(20, candles.length)) : undefined; + const volatility20 = estimateVolatility(candles.slice(-21)); + + return { + sector: "China ETF / A-share", + avgVolume20, + volumeRatio: avgVolume20 && quote.volume ? round(quote.volume / avgVolume20, 4) : undefined, + volatility20, + drawdownFromHigh: high60 && last ? round((high60 - last.close) / high60, 4) : undefined, + relativeStrength60: first60 && last ? round((last.close - first60.close) / first60.close, 4) : undefined, + support: low20, + resistance: high20, + marketCap: quote.marketCap, + riskFlags: [], + asOf: quote.asOf, + sources: [ + { name: "Sina CN quote", url: "https://hq.sinajs.cn/list=", asOf: quote.asOf }, + { name: "Sina CN kline", url: "https://money.finance.sina.com.cn/quotes_service/api/json_v2.php/CN_MarketData.getKLineData", asOf: quote.asOf } + ] + }; +} + +const nasdaqHistoricalSchema = z.object({ + data: z + .object({ + tradesTable: z + .object({ + rows: z + .array( + z.object({ + date: z.string(), + close: z.string(), + volume: z.string(), + open: z.string(), + high: z.string(), + low: z.string() + }) + ) + .optional() + }) + .optional() + }) + .nullable() + .optional() +}); + +const nasdaqInfoSchema = z.object({ + data: z + .object({ + primaryData: z + .object({ + lastSalePrice: z.string().optional(), + lastTradeTimestamp: z.string().optional() + }) + .optional(), + notifications: z.array(z.object({ headline: z.string() })).optional() + }) + .nullable() + .optional() +}); + +const nasdaqSummarySchema = z.object({ + data: z + .object({ + summaryData: z.record(z.string(), z.object({ label: z.string().optional(), value: z.union([z.string(), z.number()]).optional() })).optional() + }) + .nullable() + .optional() +}); + +const nasdaqAnalystSchema = z.object({ + data: z + .object({ + consensusOverview: z + .object({ + lowPriceTarget: z.number().optional(), + highPriceTarget: z.number().optional(), + priceTarget: z.number().optional(), + buy: z.number().optional(), + sell: z.number().optional(), + hold: z.number().optional() + }) + .optional() + }) + .nullable() + .optional() +}); + +const nasdaqHoldingsSchema = z.object({ + data: z + .object({ + ownershipSummary: z + .object({ + SharesOutstandingPCT: z.object({ value: z.string().optional() }).optional(), + ShareoutstandingTotal: z.object({ value: z.string().optional() }).optional() + }) + .optional() + }) + .nullable() + .optional() +}); + +const stocktwitsSchema = z.object({ + messages: z + .array( + z.object({ + id: z.number().optional(), + body: z.string().optional(), + created_at: z.string().optional(), + entities: z + .object({ + sentiment: z.object({ basic: z.string().optional() }).nullable().optional() + }) + .optional(), + user: z.object({ username: z.string().optional() }).optional() + }) + ) + .default([]) +}); + +const redditSchema = z.object({ + data: z + .object({ + children: z + .array( + z.object({ + data: z.object({ + title: z.string().optional(), + permalink: z.string().optional(), + created_utc: z.number().optional(), + subreddit: z.string().optional() + }) + }) + ) + .default([]) + }) + .optional() +}); + +async function fetchNasdaqJson(fetchImpl: FetchLike, url: string): Promise { + const response = await fetchImpl(url, { + headers: { + Accept: "application/json, text/plain, */*", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36", + Origin: "https://www.nasdaq.com", + Referer: "https://www.nasdaq.com/" + }, + signal: AbortSignal.timeout(15_000) + }); + if (!response.ok) { + throw new Error(`Nasdaq request failed: ${response.status}`); + } + return response.json(); +} + +async function fetchText(fetchImpl: FetchLike, url: string, headers: Record = {}): Promise { + const response = await fetchImpl(url, { + headers: { + Accept: "text/html,application/xml,text/xml,*/*", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36", + ...headers + }, + signal: AbortSignal.timeout(15_000) + }); + if (!response.ok) { + throw new Error(`Text request failed: ${response.status}`); + } + return response.text(); +} + +async function fetchGbkText(fetchImpl: FetchLike, url: string, headers: Record = {}): Promise { + const response = await fetchImpl(url, { + headers: { + Accept: "text/plain,*/*", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/124 Safari/537.36", + Referer: "https://finance.sina.com.cn/", + ...headers + }, + signal: AbortSignal.timeout(15_000) + }); + if (!response.ok) { + throw new Error(`GBK text request failed: ${response.status}`); + } + return new TextDecoder("gb18030").decode(await response.arrayBuffer()); +} + +async function fetchSinaChinaQuote(stock: WatchStock, fetchImpl: FetchLike): Promise<{ price: number; previousClose?: number; high?: number; low?: number; volume?: number; amount?: number; marketCap?: number; asOf: string }> { + const symbol = sinaChinaSymbol(stock); + const text = await fetchGbkText(fetchImpl, `https://hq.sinajs.cn/list=${symbol}`); + const match = text.match(/="([^"]*)"/); + const fields = match?.[1]?.split(",") ?? []; + const price = parseOptionalNumber(fields[3]); + if (!isFiniteNumber(price)) { + throw new Error(`Sina CN quote did not include a valid price for ${stock.display}`); + } + const date = fields[30]; + const time = fields[31]; + const amount = parseOptionalNumber(fields[9]); + return { + price, + previousClose: parseOptionalNumber(fields[2]), + high: parseOptionalNumber(fields[4]), + low: parseOptionalNumber(fields[5]), + volume: parseOptionalNumber(fields[8]), + amount, + asOf: date && time ? `${date} ${time}` : new Date().toISOString(), + }; +} + +async function fetchYahooFinanceNews(stock: WatchStock, fetchImpl: FetchLike): Promise { + const xml = await fetchText( + fetchImpl, + `https://feeds.finance.yahoo.com/rss/2.0/headline?s=${encodeURIComponent(stock.symbol)}®ion=US&lang=en-US` + ); + return parseRssItems(xml).slice(0, 10).map((item) => ({ + ...item, + source: "Yahoo Finance" + })); +} + +async function fetchStocktwitsMentions(stock: WatchStock, fetchImpl: FetchLike): Promise { + const payload = stocktwitsSchema.parse( + await fetchNasdaqJson(fetchImpl, `https://api.stocktwits.com/api/2/streams/symbol/${encodeURIComponent(stock.symbol)}.json`) + ); + return payload.messages.slice(0, 10).flatMap((message) => { + if (!message.body) { + return []; + } + return [ + { + title: message.body.slice(0, 180), + url: message.id ? `https://stocktwits.com/symbol/${encodeURIComponent(stock.symbol)}/message/${message.id}` : `https://stocktwits.com/symbol/${encodeURIComponent(stock.symbol)}`, + source: message.entities?.sentiment?.basic ? `Stocktwits ${message.entities.sentiment.basic}` : "Stocktwits", + publishedAt: message.created_at + } + ]; + }); +} + +async function fetchRedditMentions(stock: WatchStock, fetchImpl: FetchLike): Promise { + const payload = redditSchema.parse( + await fetchNasdaqJson( + fetchImpl, + `https://www.reddit.com/search.json?q=${encodeURIComponent(`${stock.symbol} stock`)}&sort=new&t=week&limit=8` + ) + ); + return (payload.data?.children ?? []).flatMap((item) => { + const title = item.data.title; + if (!title) { + return []; + } + return [ + { + title, + url: item.data.permalink ? `https://www.reddit.com${item.data.permalink}` : "https://www.reddit.com/search/", + source: item.data.subreddit ? `Reddit r/${item.data.subreddit}` : "Reddit", + publishedAt: item.data.created_utc ? new Date(item.data.created_utc * 1000).toISOString() : undefined + } + ]; + }); +} + +function parseRssItems(xml: string): RelatedNews[] { + const matches = Array.from(xml.matchAll(//gi)); + return matches.flatMap((match) => { + const item = match[0]; + const title = decodeXml(readXmlTag(item, "title")); + const link = decodeXml(readXmlTag(item, "link")); + if (!title || !link) { + return []; + } + return [ + { + title, + url: link, + publishedAt: decodeXml(readXmlTag(item, "pubDate")) || undefined + } + ]; + }); +} + +function readXmlTag(xml: string, tag: string): string { + const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i")); + return match?.[1]?.replace(/^$/i, "$1").trim() ?? ""; +} + +function decodeXml(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'"); +} + +function inferWebSentimentLabel(socialMentions: RelatedNews[], news: RelatedNews[]): string | undefined { + const text = [...socialMentions, ...news].map((item) => `${item.source ?? ""} ${item.title}`.toLowerCase()).join(" "); + if (!text) { + return undefined; + } + const bullish = (text.match(/bullish|buy|breakout|upgrade|beat|surge|rally|看多|买入/g) ?? []).length; + const bearish = (text.match(/bearish|sell|downgrade|miss|drop|lawsuit|risk|看空|卖出/g) ?? []).length; + if (bullish > bearish + 1) { + return "偏积极"; + } + if (bearish > bullish + 1) { + return "偏谨慎"; + } + return "中性"; +} + +function parseNasdaqRow(row: NasdaqHistoricalRow): PriceCandle | undefined { + const date = parseNasdaqDate(row.date); + const open = parseNasdaqNumber(row.open); + const high = parseNasdaqNumber(row.high); + const low = parseNasdaqNumber(row.low); + const close = parseNasdaqNumber(row.close); + const volume = parseNasdaqNumber(row.volume); + if (!date || !isFiniteNumber(open) || !isFiniteNumber(high) || !isFiniteNumber(low) || !isFiniteNumber(close) || !isFiniteNumber(volume)) { + return undefined; + } + return { + date, + open: round(open, 4), + high: round(high, 4), + low: round(low, 4), + close: round(close, 4), + volume: Math.round(volume) + }; +} + +function parseSinaKlineRow(row: SinaKlineRow): PriceCandle | undefined { + const open = parseOptionalNumber(row.open); + const high = parseOptionalNumber(row.high); + const low = parseOptionalNumber(row.low); + const close = parseOptionalNumber(row.close); + const volume = parseOptionalNumber(row.volume); + if (!row.day || !isFiniteNumber(open) || !isFiniteNumber(high) || !isFiniteNumber(low) || !isFiniteNumber(close) || !isFiniteNumber(volume)) { + return undefined; + } + return { + date: row.day, + open: round(open, 4), + high: round(high, 4), + low: round(low, 4), + close: round(close, 4), + volume: Math.round(volume) + }; +} + +function sinaChinaSymbol(stock: WatchStock): string { + return `${stock.display.endsWith(".SS") ? "sh" : "sz"}${stock.symbol}`; +} + +function parseNasdaqDate(value: string): string | undefined { + const match = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (!match) { + return undefined; + } + const [, month, day, year] = match; + return `${year}-${month}-${day}`; +} + +function parseNasdaqNumber(value: string | number): number | undefined { + const number = typeof value === "number" ? value : Number(value.replace(/[$,]/g, "")); + return Number.isFinite(number) ? number : undefined; +} + +function parseOptionalNumber(value: string | number | undefined): number | undefined { + return value !== undefined && value !== "" ? parseNasdaqNumber(value) : undefined; +} + +function stringifyOptional(value: string | number | undefined): string | undefined { + return value === undefined || value === "" ? undefined : String(value); +} + +function parsePercent(value: string | number | undefined): number | undefined { + if (!value) { + return undefined; + } + const number = typeof value === "number" ? value : Number(value.replace(/[%,$,]/g, "")); + return Number.isFinite(number) ? round(number / 100, 4) : undefined; +} + +function parseRange(value: string | number | undefined): { low: number; high: number } | undefined { + if (typeof value !== "string" || !value) { + return undefined; + } + const parts = value.split(/[-/]/).map((part) => parseNasdaqNumber(part.trim())).filter(isFiniteNumber); + if (parts.length < 2) { + return undefined; + } + return { low: Math.min(parts[0], parts[1]), high: Math.max(parts[0], parts[1]) }; +} + +interface AnalystConsensus { + buy?: number; + hold?: number; + sell?: number; +} + +function analystConsensusLabel(consensus: AnalystConsensus | undefined): string | undefined { + if (!consensus) { + return undefined; + } + const buy = consensus.buy ?? 0; + const hold = consensus.hold ?? 0; + const sell = consensus.sell ?? 0; + if (buy > hold && buy > sell) { + return "Buy"; + } + if (sell > buy && sell > hold) { + return "Sell"; + } + return "Hold"; +} + +function parseSharesOutstanding(value: string | undefined): number | undefined { + const sharesMillions = parseOptionalNumber(value); + return sharesMillions === undefined ? undefined : sharesMillions * 1_000_000; +} + +function calculateDrawdown(high: number | undefined, previousClose: number | undefined): number | undefined { + return high && previousClose ? round((high - previousClose) / high, 4) : undefined; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); } function stripJsonFence(text: string): string { @@ -137,7 +1218,23 @@ function peerDisplay(stock: WatchStock, index: number): string { return index === 1 ? "MSFT" : "GOOGL"; } +function symbolSeed(stock: WatchStock): number { + return stock.display.split("").reduce((sum, char) => sum + char.charCodeAt(0), 0); +} + function round(value: number, digits: number): number { const factor = 10 ** digits; return Math.round(value * factor) / factor; } + +function clampScore(value: number): number { + return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 50; +} + +function formatPct(value?: number): string { + return value === undefined ? "-" : `${round(value * 100, 2)}%`; +} + +function formatPrice(value?: number): string { + return value === undefined ? "-" : String(round(value, 2)); +} diff --git a/src/agent/report.ts b/src/agent/report.ts index 08ca580..f006236 100644 --- a/src/agent/report.ts +++ b/src/agent/report.ts @@ -60,18 +60,19 @@ function analyzeEarnings(snapshot: StockSnapshot, now: Date): EarningsSignal { } function analyzeValuation(snapshot: StockSnapshot): ValuationSignal { + const stockPe = snapshot.pe; const peerPes = snapshot.peers.map((peer) => peer.pe).filter((pe) => Number.isFinite(pe) && pe > 0); - if (!Number.isFinite(snapshot.pe) || snapshot.pe <= 0 || peerPes.length === 0) { + if (!Number.isFinite(stockPe) || stockPe === undefined || stockPe <= 0 || peerPes.length === 0) { return { label: "unknown" }; } const peerAveragePe = round(peerPes.reduce((sum, pe) => sum + pe, 0) / peerPes.length, 2); - const spreadPct = round((snapshot.pe - peerAveragePe) / peerAveragePe, 4); + const spreadPct = round((stockPe - peerAveragePe) / peerAveragePe, 4); const label = spreadPct <= -0.15 ? "discount" : spreadPct >= 0.15 ? "premium" : "fair"; return { label, - stockPe: snapshot.pe, + stockPe, peerAveragePe, spreadPct }; diff --git a/src/agent/stockDataCache.ts b/src/agent/stockDataCache.ts new file mode 100644 index 0000000..77c8f20 --- /dev/null +++ b/src/agent/stockDataCache.ts @@ -0,0 +1,380 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { calculateIndicators } from "./indicators"; +import type { + ChartPeriod, + FinancialStatement, + MarketDataProvider, + PriceCandle, + QuantAnalysis, + RelatedStockData, + StockChartData, + StockFundamentalsData, + StockQuantAnalysis, + StockRelatedData, + StockSnapshot, + WatchStock +} from "../shared/types"; + +interface CachedEntry { + cachedAt: string; + data: T; +} + +interface StockCacheFile { + snapshot?: CachedEntry; + candles?: CachedEntry; + candlesWindowYears?: number; + candlesProviderVersion?: number; + fundamentals?: CachedEntry; + relatedData?: CachedEntry; + quantAnalysis?: CachedEntry & { version?: number }; +} + +const SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000; +const CANDLES_TTL_MS = 24 * 60 * 60 * 1000; +const FUNDAMENTALS_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const RELATED_TTL_MS = 24 * 60 * 60 * 1000; +const ANALYSIS_VERSION = 4; +const CANDLES_WINDOW_YEARS = 10; +const CANDLES_PROVIDER_VERSION = 2; + +export class StockDataCache { + constructor( + private readonly cacheDir: string, + private readonly provider: MarketDataProvider, + private readonly now: () => Date = () => new Date() + ) {} + + async snapshot(stock: WatchStock, force = false): Promise { + const cache = await this.read(stock); + if (!force && cache.snapshot && !isExpired(cache.snapshot.cachedAt, SNAPSHOT_TTL_MS, this.now())) { + return cache.snapshot.data; + } + + const next = await this.provider.snapshot(stock); + await this.write(stock, { ...cache, snapshot: { cachedAt: this.now().toISOString(), data: next } }); + return next; + } + + async chart(stock: WatchStock, period: ChartPeriod = "D", force = false): Promise { + const cache = await this.read(stock); + const existing = cache.candles; + let entry: CachedEntry; + try { + if (!existing || force || cache.candlesWindowYears !== CANDLES_WINDOW_YEARS || cache.candlesProviderVersion !== CANDLES_PROVIDER_VERSION) { + entry = { cachedAt: this.now().toISOString(), data: await this.provider.candles(stock) }; + } else if (isExpired(existing.cachedAt, CANDLES_TTL_MS, this.now())) { + entry = existing; + void this.refreshCandles(stock, existing); + } else { + entry = existing; + } + } catch (error) { + if (existing) { + entry = existing; + } else { + throw error; + } + } + if (entry !== cache.candles) { + await this.write(stock, { ...cache, candles: entry, candlesWindowYears: CANDLES_WINDOW_YEARS, candlesProviderVersion: CANDLES_PROVIDER_VERSION }); + } + + const candles = aggregateCandles(entry.data, period); + return { + stock, + period, + candles, + indicators: calculateIndicators(candles), + cachedAt: entry.cachedAt + }; + } + + private async refreshCandles(stock: WatchStock, existing: CachedEntry): Promise { + try { + const cache = await this.read(stock); + if (cache.candles?.cachedAt !== existing.cachedAt) { + return; + } + const startDate = previousCalendarDate(existing.data[existing.data.length - 1]?.date); + const incremental = startDate ? await this.provider.candles(stock, startDate) : await this.provider.candles(stock); + const entry = { + cachedAt: this.now().toISOString(), + data: incremental.length ? mergeCandles(existing.data, incremental) : existing.data + }; + await this.write(stock, { ...cache, candles: entry, candlesWindowYears: CANDLES_WINDOW_YEARS, candlesProviderVersion: CANDLES_PROVIDER_VERSION }); + } catch { + // Keep stale cache when the upstream market data source is unavailable. + } + } + + async refreshChart(stock: WatchStock, period: ChartPeriod = "D"): Promise { + const cache = await this.read(stock); + const existing = cache.candles; + let entry: CachedEntry; + if (existing && cache.candlesWindowYears === CANDLES_WINDOW_YEARS && cache.candlesProviderVersion === CANDLES_PROVIDER_VERSION) { + const startDate = previousCalendarDate(existing.data[existing.data.length - 1]?.date); + const incremental = startDate ? await this.provider.candles(stock, startDate) : await this.provider.candles(stock); + entry = { + cachedAt: this.now().toISOString(), + data: incremental.length ? mergeCandles(existing.data, incremental) : existing.data + }; + } else { + entry = { cachedAt: this.now().toISOString(), data: await this.provider.candles(stock) }; + } + await this.write(stock, { ...cache, candles: entry, candlesWindowYears: CANDLES_WINDOW_YEARS, candlesProviderVersion: CANDLES_PROVIDER_VERSION }); + + const candles = aggregateCandles(entry.data, period); + return { + stock, + period, + candles, + indicators: calculateIndicators(candles), + cachedAt: entry.cachedAt + }; + } + + async fundamentals(stock: WatchStock, force = false): Promise { + const cache = await this.read(stock); + let entry: CachedEntry; + try { + entry = + !force && cache.fundamentals + ? cache.fundamentals + : { cachedAt: this.now().toISOString(), data: await this.provider.fundamentals(stock) }; + } catch { + entry = cache.fundamentals ?? { cachedAt: this.now().toISOString(), data: [] }; + } + if (entry !== cache.fundamentals) { + await this.write(stock, { ...cache, fundamentals: entry }); + } + + return { + stock, + statements: entry.data, + cachedAt: entry.cachedAt + }; + } + + async relatedData(stock: WatchStock, force = false): Promise { + const cache = await this.read(stock); + let entry: CachedEntry; + try { + entry = + !force && cache.relatedData && hasResearchSchema(cache.relatedData.data) + ? cache.relatedData + : { cachedAt: this.now().toISOString(), data: await this.provider.relatedData(stock) }; + } catch { + entry = cache.relatedData ?? { + cachedAt: this.now().toISOString(), + data: { + riskFlags: ["相关市场数据源暂不可用。"], + sources: [] + } + }; + } + if (entry !== cache.relatedData) { + await this.write(stock, { ...cache, relatedData: entry }); + } + + return { + stock, + data: entry.data, + cachedAt: entry.cachedAt + }; + } + + async quantAnalysis(stock: WatchStock, force = false): Promise { + const cache = await this.read(stock); + const hasTodayAnalysis = + cache.quantAnalysis?.version === ANALYSIS_VERSION && isSameShanghaiDate(cache.quantAnalysis.cachedAt, this.now()); + if (!force && cache.quantAnalysis?.version === ANALYSIS_VERSION) { + return { + stock, + analysis: cache.quantAnalysis.data, + generatedAt: cache.quantAnalysis.cachedAt, + cachedAt: cache.quantAnalysis.cachedAt + }; + } + + const refreshSourceData = force || !hasTodayAnalysis; + const snapshot = await this.snapshot(stock, refreshSourceData); + const chart = await this.chart(stock, "D", refreshSourceData); + const fundamentals = await this.fundamentals(stock, refreshSourceData).catch(() => ({ stock, statements: [], cachedAt: this.now().toISOString() })); + const related = await this.relatedData(stock, refreshSourceData).catch(() => ({ + stock, + data: { riskFlags: ["相关市场数据源暂不可用。"], sources: [] }, + cachedAt: this.now().toISOString() + })); + const generatedAt = this.now().toISOString(); + const analysis = await this.provider.quantAnalysis({ + stock, + snapshot, + candles: chart.candles, + indicators: chart.indicators, + fundamentals: fundamentals.statements, + relatedData: related.data + }); + const entry = { cachedAt: generatedAt, data: analysis, version: ANALYSIS_VERSION }; + await this.write(stock, { ...(await this.read(stock)), quantAnalysis: entry }); + + return { + stock, + analysis, + generatedAt, + cachedAt: entry.cachedAt + }; + } + + async analysisNeedsRefresh(stock: WatchStock): Promise { + const cache = await this.read(stock); + return cache.quantAnalysis?.version !== ANALYSIS_VERSION || !isSameShanghaiDate(cache.quantAnalysis.cachedAt, this.now()); + } + + async hasAnalysis(stock: WatchStock): Promise { + const cache = await this.read(stock); + return cache.quantAnalysis?.version === ANALYSIS_VERSION; + } + + placeholderAnalysis(stock: WatchStock): StockQuantAnalysis { + const generatedAt = this.now().toISOString(); + return { + stock, + generatedAt, + cachedAt: generatedAt, + analysis: { + action: "hold", + score: 50, + confidence: 0.1, + horizon: "后台生成中", + summary: "本地暂无 Agent 分析缓存,已在后台生成最新量化分析。当前先展示缓存数据,避免切换自选股时阻塞界面。", + factors: [ + { name: "缓存状态", score: 50, weight: 1, evidence: "等待后台 Agent 分析写入本地缓存。" }, + { name: "技术面", score: 50, weight: 0, evidence: "K线和指标可先独立查看。" }, + { name: "风险控制", score: 50, weight: 0, evidence: "完整交易计划将在后台分析完成后更新。" } + ], + tradePlan: { + direction: "wait", + rationale: "后台分析完成前不生成买入、卖出或止损价。" + }, + marketContext: ["Agent 分析正在后台生成。"], + riskNotes: ["该占位结果只表示后台任务状态,不代表实际投资判断。"] + } + }; + } + + private async read(stock: WatchStock): Promise { + try { + return JSON.parse(await readFile(this.pathFor(stock), "utf8")) as StockCacheFile; + } catch (error) { + if (isMissingFile(error)) { + return {}; + } + throw error; + } + } + + private async write(stock: WatchStock, cache: StockCacheFile): Promise { + await mkdir(this.cacheDir, { recursive: true }); + await writeFile(this.pathFor(stock), `${JSON.stringify(cache, null, 2)}\n`, "utf8"); + } + + private pathFor(stock: WatchStock): string { + return join(this.cacheDir, `${stock.display.replace(/[^A-Z0-9]/g, "_")}.json`); + } +} + +function isExpired(cachedAt: string, ttlMs: number, now: Date): boolean { + const cachedTime = new Date(cachedAt).getTime(); + return !Number.isFinite(cachedTime) || now.getTime() - cachedTime > ttlMs; +} + +function isMissingFile(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +} + +function mergeCandles(existing: PriceCandle[], incoming: PriceCandle[]): PriceCandle[] { + const byDate = new Map(); + for (const candle of existing) { + byDate.set(candle.date, candle); + } + for (const candle of incoming) { + byDate.set(candle.date, candle); + } + return Array.from(byDate.values()).sort((left, right) => left.date.localeCompare(right.date)); +} + +function previousCalendarDate(date: string | undefined): string | undefined { + if (!date) { + return undefined; + } + const value = new Date(`${date}T00:00:00.000Z`); + if (!Number.isFinite(value.getTime())) { + return undefined; + } + value.setUTCDate(value.getUTCDate() - 7); + return value.toISOString().slice(0, 10); +} + +function hasResearchSchema(data: RelatedStockData): boolean { + return Array.isArray(data.sources) && data.sources.some((source) => source.name.startsWith("Nasdaq") || source.name.startsWith("Sina")); +} + +function isSameShanghaiDate(cachedAt: string, now: Date): boolean { + const cachedTime = new Date(cachedAt); + if (!Number.isFinite(cachedTime.getTime())) { + return false; + } + return shanghaiDate(cachedTime) === shanghaiDate(now); +} + +function shanghaiDate(date: Date): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit" + }).format(date); +} + +function aggregateCandles(candles: PriceCandle[], period: ChartPeriod): PriceCandle[] { + if (period === "D") { + return candles; + } + + const grouped = new Map(); + for (const candle of candles) { + const key = periodKey(candle.date, period); + const existing = grouped.get(key); + if (!existing) { + grouped.set(key, { ...candle }); + continue; + } + existing.high = Math.max(existing.high, candle.high); + existing.low = Math.min(existing.low, candle.low); + existing.close = candle.close; + existing.volume += candle.volume; + existing.date = candle.date; + } + return Array.from(grouped.values()); +} + +function periodKey(date: string, period: ChartPeriod): string { + if (period === "M") { + return date.slice(0, 7); + } + if (period === "Y") { + return date.slice(0, 4); + } + return isoWeekKey(date); +} + +function isoWeekKey(date: string): string { + const value = new Date(`${date}T00:00:00.000Z`); + const day = value.getUTCDay() || 7; + value.setUTCDate(value.getUTCDate() + 4 - day); + const yearStart = new Date(Date.UTC(value.getUTCFullYear(), 0, 1)); + const week = Math.ceil(((value.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${value.getUTCFullYear()}-W${String(week).padStart(2, "0")}`; +} diff --git a/src/agent/symbols.ts b/src/agent/symbols.ts index 9e54c20..4ecc0f6 100644 --- a/src/agent/symbols.ts +++ b/src/agent/symbols.ts @@ -12,10 +12,11 @@ export function normalizeSymbol(input: string): NormalizedSymbol { if (suffixMatch) { const [, base, suffix] = suffixMatch; if (CN_SUFFIXES.has(suffix)) { + const exchange = inferChinaExchange(base); return { symbol: base, market: "CN", - display: `${base}.${suffix === "SH" ? "SS" : suffix}` + display: `${base}.${exchange}` }; } if (suffix === "HK" && /^\d{4,5}$/.test(base)) { @@ -43,7 +44,7 @@ export function normalizeSymbol(input: string): NormalizedSymbol { } function inferChinaExchange(symbol: string): "SS" | "SZ" { - return symbol.startsWith("6") ? "SS" : "SZ"; + return symbol.startsWith("5") || symbol.startsWith("6") || symbol.startsWith("9") ? "SS" : "SZ"; } export function marketLabel(market: Market): string { diff --git a/src/agent/watchlistStore.ts b/src/agent/watchlistStore.ts index 50472c9..b79ece0 100644 --- a/src/agent/watchlistStore.ts +++ b/src/agent/watchlistStore.ts @@ -10,7 +10,7 @@ export class WatchlistStore { return this.read(); } - async add(symbolInput: string, name?: string): Promise { + async add(symbolInput: string, name?: string, tags: string[] = []): Promise { const normalized = normalizeSymbol(symbolInput); const stocks = await this.read(); const existing = stocks.find((stock) => stock.display === normalized.display); @@ -22,12 +22,26 @@ export class WatchlistStore { ...normalized, id: normalized.display, name: name?.trim() || undefined, + tags: normalizeTags(tags), addedAt: new Date().toISOString() }; await this.write([...stocks, stock]); return stock; } + async updateTags(symbolInput: string, tags: string[]): Promise { + const normalized = normalizeSymbol(symbolInput); + const stocks = await this.read(); + const stock = stocks.find((item) => item.display === normalized.display); + if (!stock) { + throw new Error("Stock is not in watchlist"); + } + + const updated = { ...stock, tags: normalizeTags(tags) }; + await this.write(stocks.map((item) => (item.display === normalized.display ? updated : item))); + return updated; + } + async remove(symbolInput: string): Promise { const normalized = normalizeSymbol(symbolInput); const stocks = await this.read(); @@ -38,7 +52,20 @@ export class WatchlistStore { try { const content = await readFile(this.path, "utf8"); const parsed = JSON.parse(content) as WatchStock[]; - return Array.isArray(parsed) ? parsed : []; + if (!Array.isArray(parsed)) { + return []; + } + const byDisplay = new Map(); + for (const stock of parsed) { + const normalized = normalizeSymbol(stock.display ?? stock.symbol); + byDisplay.set(normalized.display, { + ...stock, + ...normalized, + id: normalized.display, + tags: normalizeTags(stock.tags ?? []) + }); + } + return Array.from(byDisplay.values()); } catch (error) { if (isMissingFile(error)) { return []; @@ -56,3 +83,14 @@ export class WatchlistStore { function isMissingFile(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; } + +function normalizeTags(tags: string[]): string[] { + return Array.from( + new Set( + tags + .map((tag) => tag.trim()) + .filter(Boolean) + .map((tag) => tag.slice(0, 24)) + ) + ).slice(0, 8); +} diff --git a/src/shared/types.ts b/src/shared/types.ts index e4239fc..42cbf69 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,6 +1,7 @@ export type Market = "CN" | "HK" | "US"; export type RecommendationAction = "buy_watch" | "hold" | "avoid"; +export type ChartPeriod = "D" | "W" | "M" | "Y"; export interface NormalizedSymbol { symbol: string; @@ -11,6 +12,7 @@ export interface NormalizedSymbol { export interface WatchStock extends NormalizedSymbol { id: string; name?: string; + tags: string[]; addedAt: string; } @@ -21,6 +23,12 @@ export interface RelatedNews { publishedAt?: string; } +export interface DataSourceRef { + name: string; + url?: string; + asOf?: string; +} + export interface PeerValuation { display: string; pe: number; @@ -29,17 +37,170 @@ export interface PeerValuation { export interface StockSnapshot { price: number; currency: string; - pe: number; + pe?: number; forwardPe?: number; revenueGrowth?: number; profitGrowth?: number; nextEarningsDate?: string; peers: PeerValuation[]; news: RelatedNews[]; + asOf?: string; + sources?: DataSourceRef[]; } export interface MarketDataProvider { snapshot(stock: WatchStock): Promise; + candles(stock: WatchStock, startDate?: string): Promise; + fundamentals(stock: WatchStock): Promise; + relatedData(stock: WatchStock): Promise; + quantAnalysis(input: QuantAnalysisInput): Promise; +} + +export interface PriceCandle { + date: string; + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +export interface IndicatorPoint { + date: string; + ma5?: number; + ma20?: number; + ma60?: number; + ma120?: number; + ma200?: number; + rsi14?: number; + macd?: number; + macdSignal?: number; + macdHistogram?: number; +} + +export interface StockChartData { + stock: WatchStock; + period: ChartPeriod; + candles: PriceCandle[]; + indicators: IndicatorPoint[]; + cachedAt: string; +} + +export interface FinancialStatement { + period: string; + reportDate?: string; + revenue: number; + netIncome: number; + eps?: number; + grossMargin?: number; + operatingCashFlow?: number; + debtToAsset?: number; +} + +export interface StockFundamentalsData { + stock: WatchStock; + statements: FinancialStatement[]; + cachedAt: string; +} + +export interface RelatedStockData { + sector?: string; + industryRank?: number; + beta?: number; + avgVolume20?: number; + volumeRatio?: number; + turnoverRate?: number; + volatility20?: number; + drawdownFromHigh?: number; + relativeStrength60?: number; + support?: number; + resistance?: number; + capitalFlow?: number; + sentimentScore?: number; + newsHeat?: number; + marketCap?: number; + enterpriseValue?: number; + sharesOutstanding?: number; + freeFloat?: number; + priceToBook?: number; + priceToSales?: number; + pegRatio?: number; + evToEbitda?: number; + dividendYield?: number; + freeCashFlowYield?: number; + grossMarginTtm?: number; + netMarginTtm?: number; + roeTtm?: number; + roaTtm?: number; + debtToEquity?: number; + currentRatio?: number; + revenueCagr3y?: number; + epsGrowthTtm?: number; + analystRating?: string; + analystTargetPrice?: number; + institutionalOwnership?: number; + insiderOwnership?: number; + shortInterestRatio?: number; + nextExDividendDate?: string; + indexMembership?: string[]; + recentNews?: RelatedNews[]; + socialMentions?: RelatedNews[]; + socialDiscussionCount?: number; + webSentimentLabel?: string; + riskFlags: string[]; + asOf?: string; + sources: DataSourceRef[]; +} + +export interface StockRelatedData { + stock: WatchStock; + data: RelatedStockData; + cachedAt: string; +} + +export interface QuantFactor { + name: string; + score: number; + weight: number; + evidence: string; +} + +export interface QuantAnalysis { + action: RecommendationAction; + score: number; + confidence: number; + horizon: string; + summary: string; + factors: QuantFactor[]; + tradePlan?: QuantTradePlan; + marketContext?: string[]; + riskNotes: string[]; +} + +export interface QuantTradePlan { + direction: "long" | "wait" | "reduce" | "avoid"; + entryPrice?: number; + targetPrice?: number; + stopLoss?: number; + expectedReturnPct?: number; + invalidation?: string; + rationale: string; +} + +export interface StockQuantAnalysis { + stock: WatchStock; + analysis: QuantAnalysis; + generatedAt: string; + cachedAt: string; +} + +export interface QuantAnalysisInput { + stock: WatchStock; + snapshot: StockSnapshot; + candles: PriceCandle[]; + indicators: IndicatorPoint[]; + fundamentals: FinancialStatement[]; + relatedData: RelatedStockData; } export interface EarningsSignal { diff --git a/tests/provider.test.ts b/tests/provider.test.ts index 771b3eb..93a8066 100644 --- a/tests/provider.test.ts +++ b/tests/provider.test.ts @@ -9,11 +9,12 @@ const stock: WatchStock = { display: "AAPL", market: "US", name: "Apple", + tags: ["core"], addedAt: "2026-05-11T00:00:00.000Z" }; describe("IpadsResponsesProvider", () => { - it("uses the fixed IPADS Responses API endpoint with IPADS_API_KEY", async () => { + it("uses the fixed IPADS Responses API endpoint for agent quant analysis", async () => { const fetchImpl = vi.fn(async () => { return new Response( JSON.stringify({ @@ -25,12 +26,17 @@ describe("IpadsResponsesProvider", () => { { type: "output_text", text: JSON.stringify({ - price: 100, - currency: "USD", - pe: 20, - forwardPe: 18, - peers: [{ display: "MSFT", pe: 25 }], - news: [{ title: "Apple earnings preview", url: "https://example.test/news" }] + action: "buy_watch", + score: 72, + confidence: 0.68, + horizon: "1-3 months", + summary: "Trend and valuation are supportive.", + factors: [ + { name: "trend", score: 75, weight: 0.4, evidence: "above MA20" }, + { name: "valuation", score: 68, weight: 0.3, evidence: "reasonable" }, + { name: "risk", score: 70, weight: 0.3, evidence: "controlled" } + ], + riskNotes: [] }) } ] @@ -42,7 +48,14 @@ describe("IpadsResponsesProvider", () => { }); const provider = new IpadsResponsesProvider("secret", fetchImpl); - const snapshot = await provider.snapshot(stock); + const analysis = await provider.quantAnalysis({ + stock, + snapshot: { price: 100, currency: "USD", pe: 20, peers: [], news: [] }, + candles: [{ date: "2026-05-11", open: 100, high: 102, low: 99, close: 101, volume: 1_000_000 }], + indicators: [{ date: "2026-05-11", ma20: 98 }], + fundamentals: [], + relatedData: { riskFlags: [], sources: [] } + }); expect(fetchImpl).toHaveBeenCalledWith( `${IPADS_BASE_URL}/${IPADS_WIRE_API}`, @@ -60,7 +73,53 @@ describe("IpadsResponsesProvider", () => { expect(requestBody.input).toContain("\"symbol\":\"AAPL\""); expect(typeof requestBody.input).toBe("string"); expect(requestBody).not.toHaveProperty("metadata"); - expect(snapshot.news[0]?.title).toBe("Apple earnings preview"); + expect(analysis.action).toBe("buy_watch"); + }); + + it("falls back to finite local analysis when remote agent cannot analyze empty market data", async () => { + const fetchImpl = vi.fn(async () => { + return new Response( + JSON.stringify({ + output_text: JSON.stringify({ + action: "hold", + score: 50, + confidence: 0.4, + horizon: "1-3个月", + summary: "invalid", + factors: [ + { name: "trend", score: 50, weight: 1, evidence: "empty" }, + { name: "valuation", score: 50, weight: 0, evidence: "empty" }, + { name: "risk", score: 50, weight: 0, evidence: "empty" } + ], + tradePlan: { + direction: "wait", + entryPrice: null, + targetPrice: null, + stopLoss: null, + expectedReturnPct: null, + rationale: "invalid null prices" + }, + riskNotes: [] + }) + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }); + const provider = new IpadsResponsesProvider("secret", fetchImpl); + + const analysis = await provider.quantAnalysis({ + stock: { ...stock, symbol: "DRAM", display: "DRAM", id: "DRAM" }, + snapshot: { price: 0, currency: "USD", peers: [], news: [] }, + candles: [], + indicators: [], + fundamentals: [], + relatedData: { riskFlags: [], sources: [] } + }); + + expect(Number.isFinite(analysis.score)).toBe(true); + expect(Number.isFinite(analysis.confidence)).toBe(true); + expect(analysis.factors.every((factor) => Number.isFinite(factor.score))).toBe(true); + expect(analysis.tradePlan?.entryPrice).toBeUndefined(); }); }); @@ -96,4 +155,63 @@ describe("DemoMarketDataProvider", () => { }) ); }); + + it("returns chart candles and financial statements for local cached views", async () => { + const provider = new DemoMarketDataProvider(); + + await expect(provider.candles(stock)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + date: expect.any(String), + open: expect.any(Number), + high: expect.any(Number), + low: expect.any(Number), + close: expect.any(Number), + volume: expect.any(Number) + }) + ]) + ); + await expect(provider.fundamentals(stock)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + period: expect.any(String), + revenue: expect.any(Number), + netIncome: expect.any(Number) + }) + ]) + ); + }); + + it("returns related data and a structured quantitative analysis", async () => { + const provider = new DemoMarketDataProvider(); + const candles = await provider.candles(stock); + const relatedData = await provider.relatedData(stock); + + expect(relatedData).toEqual( + expect.objectContaining({ + sector: expect.any(String), + avgVolume20: expect.any(Number), + sentimentScore: expect.any(Number) + }) + ); + await expect( + provider.quantAnalysis({ + stock, + snapshot: await provider.snapshot(stock), + candles, + indicators: [], + fundamentals: await provider.fundamentals(stock), + relatedData + }) + ).resolves.toEqual( + expect.objectContaining({ + action: expect.stringMatching(/buy_watch|hold|avoid/), + score: expect.any(Number), + factors: expect.any(Array), + tradePlan: expect.objectContaining({ + rationale: expect.any(String) + }) + }) + ); + }); }); diff --git a/tests/report.test.ts b/tests/report.test.ts index fe85fcf..6388a8e 100644 --- a/tests/report.test.ts +++ b/tests/report.test.ts @@ -9,6 +9,7 @@ const watchlist: WatchStock[] = [ display: "AAPL", market: "US", name: "Apple", + tags: ["core"], addedAt: "2026-05-11T00:00:00.000Z" }, { @@ -17,6 +18,7 @@ const watchlist: WatchStock[] = [ display: "00700.HK", market: "HK", name: "Tencent", + tags: ["internet"], addedAt: "2026-05-11T00:00:00.000Z" } ]; @@ -51,6 +53,44 @@ const provider: MarketDataProvider = { peers: [{ display: "9988.HK", pe: 18 }], news: [] }; + }, + async candles() { + return []; + }, + async fundamentals() { + return []; + }, + async relatedData() { + return { + sector: "Technology", + avgVolume20: 1_000_000, + volumeRatio: 1, + volatility20: 0.02, + drawdownFromHigh: 0.1, + relativeStrength60: 0.04, + support: 90, + resistance: 120, + capitalFlow: 0.1, + sentimentScore: 0.6, + newsHeat: 0.5, + riskFlags: [], + sources: [] + }; + }, + async quantAnalysis() { + return { + action: "hold", + score: 55, + confidence: 0.5, + horizon: "1-3 months", + summary: "Hold", + factors: [ + { name: "trend", score: 55, weight: 1, evidence: "neutral" }, + { name: "valuation", score: 55, weight: 0, evidence: "neutral" }, + { name: "risk", score: 55, weight: 0, evidence: "neutral" } + ], + riskNotes: [] + }; } }; diff --git a/tests/stockDataCache.test.ts b/tests/stockDataCache.test.ts new file mode 100644 index 0000000..94684f5 --- /dev/null +++ b/tests/stockDataCache.test.ts @@ -0,0 +1,179 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StockDataCache } from "../src/agent/stockDataCache"; +import type { MarketDataProvider, WatchStock } from "../src/shared/types"; + +let tempDir: string | undefined; + +afterEach(async () => { + if (tempDir) { + await rm(tempDir, { force: true, recursive: true }); + tempDir = undefined; + } +}); + +const stock: WatchStock = { + id: "AAPL", + symbol: "AAPL", + display: "AAPL", + market: "US", + name: "Apple", + tags: ["core"], + addedAt: "2026-05-11T00:00:00.000Z" +}; + +describe("StockDataCache", () => { + it("persists chart and fundamentals data and reuses the local cache", async () => { + tempDir = await mkdtemp(join(tmpdir(), "stock-agent-cache-")); + const provider: MarketDataProvider = { + snapshot: vi.fn(async () => ({ + price: 100, + currency: "USD", + pe: 20, + peers: [], + news: [] + })), + candles: vi.fn(async () => + Array.from({ length: 1010 }, (_, index) => ({ + date: new Date(Date.UTC(2022, 0, 3 + index)).toISOString().slice(0, 10), + open: 100 + index, + high: 102 + index, + low: 99 + index, + close: 101 + index, + volume: 1_000_000 + index + })) + ), + fundamentals: vi.fn(async () => [ + { + period: "2026Q1", + revenue: 1_000_000, + netIncome: 120_000 + } + ]), + relatedData: vi.fn(async () => ({ + sector: "Technology", + avgVolume20: 1_000_000, + volumeRatio: 1.1, + volatility20: 0.02, + drawdownFromHigh: 0.08, + relativeStrength60: 0.05, + support: 98, + resistance: 112, + capitalFlow: 0.1, + sentimentScore: 0.6, + newsHeat: 0.5, + riskFlags: [], + sources: [] + })), + quantAnalysis: vi.fn(async () => ({ + action: "buy_watch" as const, + score: 72, + confidence: 0.7, + horizon: "1-3 months", + summary: "Positive", + factors: [ + { name: "trend", score: 70, weight: 0.4, evidence: "above average" }, + { name: "valuation", score: 68, weight: 0.3, evidence: "reasonable" }, + { name: "sentiment", score: 74, weight: 0.3, evidence: "supportive" } + ], + riskNotes: [] + })) + }; + const cache = new StockDataCache(tempDir, provider, () => new Date("2026-05-11T00:00:00.000Z")); + + await cache.chart(stock); + await cache.chart(stock); + await cache.fundamentals(stock); + await cache.fundamentals(stock); + + expect(provider.candles).toHaveBeenCalledTimes(1); + expect(provider.fundamentals).toHaveBeenCalledTimes(1); + await expect(cache.snapshot(stock)).resolves.toEqual(expect.objectContaining({ price: 100 })); + await expect(cache.snapshot(stock)).resolves.toEqual(expect.objectContaining({ price: 100 })); + expect(provider.snapshot).toHaveBeenCalledTimes(1); + await expect(cache.chart(stock, "W")).resolves.toEqual(expect.objectContaining({ period: "W" })); + await expect(cache.relatedData(stock)).resolves.toEqual(expect.objectContaining({ data: expect.objectContaining({ sector: "Technology" }) })); + await expect(cache.quantAnalysis(stock)).resolves.toEqual(expect.objectContaining({ analysis: expect.objectContaining({ score: 72 }) })); + await expect(cache.quantAnalysis(stock)).resolves.toEqual(expect.objectContaining({ analysis: expect.objectContaining({ score: 72 }) })); + expect(provider.quantAnalysis).toHaveBeenCalledTimes(1); + }); + + it("accepts real short listing history instead of requiring a full ten years", async () => { + tempDir = await mkdtemp(join(tmpdir(), "stock-agent-cache-")); + const provider: MarketDataProvider = { + snapshot: vi.fn(async () => ({ price: 18, currency: "USD", peers: [], news: [] })), + candles: vi.fn(async () => + Array.from({ length: 225 }, (_, index) => ({ + date: new Date(Date.UTC(2025, 5, 18 + index)).toISOString().slice(0, 10), + open: 18 + index / 100, + high: 19 + index / 100, + low: 17 + index / 100, + close: 18.5 + index / 100, + volume: 800_000 + index + })) + ), + fundamentals: vi.fn(async () => []), + relatedData: vi.fn(async () => ({ riskFlags: [], sources: [] })), + quantAnalysis: vi.fn(async () => ({ + action: "hold" as const, + score: 58, + confidence: 0.6, + horizon: "1-3个月", + summary: "等待确认", + factors: [ + { name: "趋势", score: 58, weight: 0.4, evidence: "短历史可用" }, + { name: "估值", score: 50, weight: 0.3, evidence: "数据不足" }, + { name: "风险", score: 55, weight: 0.3, evidence: "控制仓位" } + ], + riskNotes: [] + })) + }; + const cache = new StockDataCache(tempDir, provider, () => new Date("2026-05-12T00:00:00.000Z")); + + await expect(cache.chart({ ...stock, id: "SLDE", symbol: "SLDE", display: "SLDE" })).resolves.toEqual( + expect.objectContaining({ + candles: expect.arrayContaining([expect.objectContaining({ date: expect.any(String) })]) + }) + ); + expect(provider.candles).toHaveBeenCalledTimes(1); + }); + + it("caches an empty historical result without treating it as an error", async () => { + tempDir = await mkdtemp(join(tmpdir(), "stock-agent-cache-")); + const provider: MarketDataProvider = { + snapshot: vi.fn(async () => ({ price: 18, currency: "USD", peers: [], news: [] })), + candles: vi.fn(async () => []), + fundamentals: vi.fn(async () => []), + relatedData: vi.fn(async () => ({ riskFlags: [], sources: [] })), + quantAnalysis: vi.fn(async () => ({ + action: "hold" as const, + score: 50, + confidence: 0.4, + horizon: "1-3个月", + summary: "无K线数据,忽略技术面。", + factors: [ + { name: "趋势", score: 50, weight: 0.4, evidence: "无历史数据" }, + { name: "估值", score: 50, weight: 0.3, evidence: "数据不足" }, + { name: "风险", score: 50, weight: 0.3, evidence: "等待数据" } + ], + riskNotes: [] + })) + }; + const cache = new StockDataCache(tempDir, provider, () => new Date("2026-05-12T00:00:00.000Z")); + + await expect(cache.chart({ ...stock, id: "NEW", symbol: "NEW", display: "NEW" })).resolves.toEqual( + expect.objectContaining({ + candles: [], + indicators: [] + }) + ); + await expect(cache.chart({ ...stock, id: "NEW", symbol: "NEW", display: "NEW" })).resolves.toEqual( + expect.objectContaining({ + candles: [] + }) + ); + expect(provider.candles).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/symbols.test.ts b/tests/symbols.test.ts index a0d2932..1acfe9c 100644 --- a/tests/symbols.test.ts +++ b/tests/symbols.test.ts @@ -5,6 +5,8 @@ describe("normalizeSymbol", () => { it.each([ ["600519", { symbol: "600519", market: "CN", display: "600519.SS" }], ["000001.SZ", { symbol: "000001", market: "CN", display: "000001.SZ" }], + ["512480", { symbol: "512480", market: "CN", display: "512480.SS" }], + ["512480.SZ", { symbol: "512480", market: "CN", display: "512480.SS" }], ["00700.hk", { symbol: "00700", market: "HK", display: "00700.HK" }], ["AAPL", { symbol: "AAPL", market: "US", display: "AAPL" }], ["msft.us", { symbol: "MSFT", market: "US", display: "MSFT" }] diff --git a/tests/watchlistStore.test.ts b/tests/watchlistStore.test.ts index 41df6e8..27075ce 100644 --- a/tests/watchlistStore.test.ts +++ b/tests/watchlistStore.test.ts @@ -19,13 +19,13 @@ describe("WatchlistStore", () => { const path = join(tempDir, "watchlist.json"); const store = new WatchlistStore(path); - await store.add("aapl", "Apple"); + await store.add("aapl", "Apple", ["Core", " AI ", "Core"]); await store.add("AAPL", "Duplicate"); await store.add("00700.HK", "Tencent"); expect(await store.list()).toEqual([ - expect.objectContaining({ display: "AAPL", market: "US", name: "Apple" }), - expect.objectContaining({ display: "00700.HK", market: "HK", name: "Tencent" }) + expect.objectContaining({ display: "AAPL", market: "US", name: "Apple", tags: ["Core", "AI"] }), + expect.objectContaining({ display: "00700.HK", market: "HK", name: "Tencent", tags: [] }) ]); const reloaded = new WatchlistStore(path); @@ -41,4 +41,23 @@ describe("WatchlistStore", () => { expect(await store.list()).toEqual([]); }); + + it("updates tags for a persisted stock", async () => { + tempDir = await mkdtemp(join(tmpdir(), "stock-agent-")); + const store = new WatchlistStore(join(tempDir, "watchlist.json")); + + await store.add("MSFT", "Microsoft"); + await store.updateTags("MSFT.US", ["cloud", "quality"]); + + expect((await store.list())[0]).toEqual(expect.objectContaining({ tags: ["cloud", "quality"] })); + }); + + it("canonicalizes persisted China ETF exchange suffixes", async () => { + tempDir = await mkdtemp(join(tmpdir(), "stock-agent-")); + const store = new WatchlistStore(join(tempDir, "watchlist.json")); + + await store.add("512480.SZ", "Semiconductor ETF"); + + expect((await store.list())[0]).toEqual(expect.objectContaining({ display: "512480.SS", id: "512480.SS" })); + }); });