feat(data): extend market data provider with charts, fundamentals, and quant analysis
Adds a stock data cache layer and indicator helpers (MA/RSI/MACD), expands the MarketDataProvider interface with candles, fundamentals, related-data, and quant analysis, and threads tags + source attribution through the shared types. Updates affected agent tests. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
119
src/agent/indicators.ts
Normal file
119
src/agent/indicators.ts
Normal file
@@ -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<number | undefined>, window: number): Array<number | undefined> {
|
||||||
|
const result: Array<number | undefined> = [];
|
||||||
|
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<number | undefined> {
|
||||||
|
const result: Array<number | undefined> = [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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -60,18 +60,19 @@ function analyzeEarnings(snapshot: StockSnapshot, now: Date): EarningsSignal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function analyzeValuation(snapshot: StockSnapshot): ValuationSignal {
|
function analyzeValuation(snapshot: StockSnapshot): ValuationSignal {
|
||||||
|
const stockPe = snapshot.pe;
|
||||||
const peerPes = snapshot.peers.map((peer) => peer.pe).filter((pe) => Number.isFinite(pe) && pe > 0);
|
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" };
|
return { label: "unknown" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const peerAveragePe = round(peerPes.reduce((sum, pe) => sum + pe, 0) / peerPes.length, 2);
|
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";
|
const label = spreadPct <= -0.15 ? "discount" : spreadPct >= 0.15 ? "premium" : "fair";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label,
|
label,
|
||||||
stockPe: snapshot.pe,
|
stockPe,
|
||||||
peerAveragePe,
|
peerAveragePe,
|
||||||
spreadPct
|
spreadPct
|
||||||
};
|
};
|
||||||
|
|||||||
380
src/agent/stockDataCache.ts
Normal file
380
src/agent/stockDataCache.ts
Normal file
@@ -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<T> {
|
||||||
|
cachedAt: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StockCacheFile {
|
||||||
|
snapshot?: CachedEntry<StockSnapshot>;
|
||||||
|
candles?: CachedEntry<PriceCandle[]>;
|
||||||
|
candlesWindowYears?: number;
|
||||||
|
candlesProviderVersion?: number;
|
||||||
|
fundamentals?: CachedEntry<FinancialStatement[]>;
|
||||||
|
relatedData?: CachedEntry<RelatedStockData>;
|
||||||
|
quantAnalysis?: CachedEntry<QuantAnalysis> & { 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<StockSnapshot> {
|
||||||
|
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<StockChartData> {
|
||||||
|
const cache = await this.read(stock);
|
||||||
|
const existing = cache.candles;
|
||||||
|
let entry: CachedEntry<PriceCandle[]>;
|
||||||
|
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<PriceCandle[]>): Promise<void> {
|
||||||
|
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<StockChartData> {
|
||||||
|
const cache = await this.read(stock);
|
||||||
|
const existing = cache.candles;
|
||||||
|
let entry: CachedEntry<PriceCandle[]>;
|
||||||
|
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<StockFundamentalsData> {
|
||||||
|
const cache = await this.read(stock);
|
||||||
|
let entry: CachedEntry<FinancialStatement[]>;
|
||||||
|
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<StockRelatedData> {
|
||||||
|
const cache = await this.read(stock);
|
||||||
|
let entry: CachedEntry<RelatedStockData>;
|
||||||
|
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<StockQuantAnalysis> {
|
||||||
|
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<boolean> {
|
||||||
|
const cache = await this.read(stock);
|
||||||
|
return cache.quantAnalysis?.version !== ANALYSIS_VERSION || !isSameShanghaiDate(cache.quantAnalysis.cachedAt, this.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasAnalysis(stock: WatchStock): Promise<boolean> {
|
||||||
|
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<StockCacheFile> {
|
||||||
|
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<void> {
|
||||||
|
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<string, PriceCandle>();
|
||||||
|
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<string, PriceCandle>();
|
||||||
|
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")}`;
|
||||||
|
}
|
||||||
@@ -12,10 +12,11 @@ export function normalizeSymbol(input: string): NormalizedSymbol {
|
|||||||
if (suffixMatch) {
|
if (suffixMatch) {
|
||||||
const [, base, suffix] = suffixMatch;
|
const [, base, suffix] = suffixMatch;
|
||||||
if (CN_SUFFIXES.has(suffix)) {
|
if (CN_SUFFIXES.has(suffix)) {
|
||||||
|
const exchange = inferChinaExchange(base);
|
||||||
return {
|
return {
|
||||||
symbol: base,
|
symbol: base,
|
||||||
market: "CN",
|
market: "CN",
|
||||||
display: `${base}.${suffix === "SH" ? "SS" : suffix}`
|
display: `${base}.${exchange}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (suffix === "HK" && /^\d{4,5}$/.test(base)) {
|
if (suffix === "HK" && /^\d{4,5}$/.test(base)) {
|
||||||
@@ -43,7 +44,7 @@ export function normalizeSymbol(input: string): NormalizedSymbol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function inferChinaExchange(symbol: string): "SS" | "SZ" {
|
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 {
|
export function marketLabel(market: Market): string {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export class WatchlistStore {
|
|||||||
return this.read();
|
return this.read();
|
||||||
}
|
}
|
||||||
|
|
||||||
async add(symbolInput: string, name?: string): Promise<WatchStock> {
|
async add(symbolInput: string, name?: string, tags: string[] = []): Promise<WatchStock> {
|
||||||
const normalized = normalizeSymbol(symbolInput);
|
const normalized = normalizeSymbol(symbolInput);
|
||||||
const stocks = await this.read();
|
const stocks = await this.read();
|
||||||
const existing = stocks.find((stock) => stock.display === normalized.display);
|
const existing = stocks.find((stock) => stock.display === normalized.display);
|
||||||
@@ -22,12 +22,26 @@ export class WatchlistStore {
|
|||||||
...normalized,
|
...normalized,
|
||||||
id: normalized.display,
|
id: normalized.display,
|
||||||
name: name?.trim() || undefined,
|
name: name?.trim() || undefined,
|
||||||
|
tags: normalizeTags(tags),
|
||||||
addedAt: new Date().toISOString()
|
addedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
await this.write([...stocks, stock]);
|
await this.write([...stocks, stock]);
|
||||||
return stock;
|
return stock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateTags(symbolInput: string, tags: string[]): Promise<WatchStock> {
|
||||||
|
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<void> {
|
async remove(symbolInput: string): Promise<void> {
|
||||||
const normalized = normalizeSymbol(symbolInput);
|
const normalized = normalizeSymbol(symbolInput);
|
||||||
const stocks = await this.read();
|
const stocks = await this.read();
|
||||||
@@ -38,7 +52,20 @@ export class WatchlistStore {
|
|||||||
try {
|
try {
|
||||||
const content = await readFile(this.path, "utf8");
|
const content = await readFile(this.path, "utf8");
|
||||||
const parsed = JSON.parse(content) as WatchStock[];
|
const parsed = JSON.parse(content) as WatchStock[];
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const byDisplay = new Map<string, WatchStock>();
|
||||||
|
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) {
|
} catch (error) {
|
||||||
if (isMissingFile(error)) {
|
if (isMissingFile(error)) {
|
||||||
return [];
|
return [];
|
||||||
@@ -56,3 +83,14 @@ export class WatchlistStore {
|
|||||||
function isMissingFile(error: unknown): boolean {
|
function isMissingFile(error: unknown): boolean {
|
||||||
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type Market = "CN" | "HK" | "US";
|
export type Market = "CN" | "HK" | "US";
|
||||||
|
|
||||||
export type RecommendationAction = "buy_watch" | "hold" | "avoid";
|
export type RecommendationAction = "buy_watch" | "hold" | "avoid";
|
||||||
|
export type ChartPeriod = "D" | "W" | "M" | "Y";
|
||||||
|
|
||||||
export interface NormalizedSymbol {
|
export interface NormalizedSymbol {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
@@ -11,6 +12,7 @@ export interface NormalizedSymbol {
|
|||||||
export interface WatchStock extends NormalizedSymbol {
|
export interface WatchStock extends NormalizedSymbol {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
tags: string[];
|
||||||
addedAt: string;
|
addedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +23,12 @@ export interface RelatedNews {
|
|||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DataSourceRef {
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
asOf?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PeerValuation {
|
export interface PeerValuation {
|
||||||
display: string;
|
display: string;
|
||||||
pe: number;
|
pe: number;
|
||||||
@@ -29,17 +37,170 @@ export interface PeerValuation {
|
|||||||
export interface StockSnapshot {
|
export interface StockSnapshot {
|
||||||
price: number;
|
price: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
pe: number;
|
pe?: number;
|
||||||
forwardPe?: number;
|
forwardPe?: number;
|
||||||
revenueGrowth?: number;
|
revenueGrowth?: number;
|
||||||
profitGrowth?: number;
|
profitGrowth?: number;
|
||||||
nextEarningsDate?: string;
|
nextEarningsDate?: string;
|
||||||
peers: PeerValuation[];
|
peers: PeerValuation[];
|
||||||
news: RelatedNews[];
|
news: RelatedNews[];
|
||||||
|
asOf?: string;
|
||||||
|
sources?: DataSourceRef[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MarketDataProvider {
|
export interface MarketDataProvider {
|
||||||
snapshot(stock: WatchStock): Promise<StockSnapshot>;
|
snapshot(stock: WatchStock): Promise<StockSnapshot>;
|
||||||
|
candles(stock: WatchStock, startDate?: string): Promise<PriceCandle[]>;
|
||||||
|
fundamentals(stock: WatchStock): Promise<FinancialStatement[]>;
|
||||||
|
relatedData(stock: WatchStock): Promise<RelatedStockData>;
|
||||||
|
quantAnalysis(input: QuantAnalysisInput): Promise<QuantAnalysis>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
export interface EarningsSignal {
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ const stock: WatchStock = {
|
|||||||
display: "AAPL",
|
display: "AAPL",
|
||||||
market: "US",
|
market: "US",
|
||||||
name: "Apple",
|
name: "Apple",
|
||||||
|
tags: ["core"],
|
||||||
addedAt: "2026-05-11T00:00:00.000Z"
|
addedAt: "2026-05-11T00:00:00.000Z"
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("IpadsResponsesProvider", () => {
|
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 () => {
|
const fetchImpl = vi.fn(async () => {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -25,12 +26,17 @@ describe("IpadsResponsesProvider", () => {
|
|||||||
{
|
{
|
||||||
type: "output_text",
|
type: "output_text",
|
||||||
text: JSON.stringify({
|
text: JSON.stringify({
|
||||||
price: 100,
|
action: "buy_watch",
|
||||||
currency: "USD",
|
score: 72,
|
||||||
pe: 20,
|
confidence: 0.68,
|
||||||
forwardPe: 18,
|
horizon: "1-3 months",
|
||||||
peers: [{ display: "MSFT", pe: 25 }],
|
summary: "Trend and valuation are supportive.",
|
||||||
news: [{ title: "Apple earnings preview", url: "https://example.test/news" }]
|
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 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(
|
expect(fetchImpl).toHaveBeenCalledWith(
|
||||||
`${IPADS_BASE_URL}/${IPADS_WIRE_API}`,
|
`${IPADS_BASE_URL}/${IPADS_WIRE_API}`,
|
||||||
@@ -60,7 +73,53 @@ describe("IpadsResponsesProvider", () => {
|
|||||||
expect(requestBody.input).toContain("\"symbol\":\"AAPL\"");
|
expect(requestBody.input).toContain("\"symbol\":\"AAPL\"");
|
||||||
expect(typeof requestBody.input).toBe("string");
|
expect(typeof requestBody.input).toBe("string");
|
||||||
expect(requestBody).not.toHaveProperty("metadata");
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const watchlist: WatchStock[] = [
|
|||||||
display: "AAPL",
|
display: "AAPL",
|
||||||
market: "US",
|
market: "US",
|
||||||
name: "Apple",
|
name: "Apple",
|
||||||
|
tags: ["core"],
|
||||||
addedAt: "2026-05-11T00:00:00.000Z"
|
addedAt: "2026-05-11T00:00:00.000Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -17,6 +18,7 @@ const watchlist: WatchStock[] = [
|
|||||||
display: "00700.HK",
|
display: "00700.HK",
|
||||||
market: "HK",
|
market: "HK",
|
||||||
name: "Tencent",
|
name: "Tencent",
|
||||||
|
tags: ["internet"],
|
||||||
addedAt: "2026-05-11T00:00:00.000Z"
|
addedAt: "2026-05-11T00:00:00.000Z"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -51,6 +53,44 @@ const provider: MarketDataProvider = {
|
|||||||
peers: [{ display: "9988.HK", pe: 18 }],
|
peers: [{ display: "9988.HK", pe: 18 }],
|
||||||
news: []
|
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: []
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
179
tests/stockDataCache.test.ts
Normal file
179
tests/stockDataCache.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,8 @@ describe("normalizeSymbol", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
["600519", { symbol: "600519", market: "CN", display: "600519.SS" }],
|
["600519", { symbol: "600519", market: "CN", display: "600519.SS" }],
|
||||||
["000001.SZ", { symbol: "000001", market: "CN", display: "000001.SZ" }],
|
["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" }],
|
["00700.hk", { symbol: "00700", market: "HK", display: "00700.HK" }],
|
||||||
["AAPL", { symbol: "AAPL", market: "US", display: "AAPL" }],
|
["AAPL", { symbol: "AAPL", market: "US", display: "AAPL" }],
|
||||||
["msft.us", { symbol: "MSFT", market: "US", display: "MSFT" }]
|
["msft.us", { symbol: "MSFT", market: "US", display: "MSFT" }]
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ describe("WatchlistStore", () => {
|
|||||||
const path = join(tempDir, "watchlist.json");
|
const path = join(tempDir, "watchlist.json");
|
||||||
const store = new WatchlistStore(path);
|
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("AAPL", "Duplicate");
|
||||||
await store.add("00700.HK", "Tencent");
|
await store.add("00700.HK", "Tencent");
|
||||||
|
|
||||||
expect(await store.list()).toEqual([
|
expect(await store.list()).toEqual([
|
||||||
expect.objectContaining({ display: "AAPL", market: "US", name: "Apple" }),
|
expect.objectContaining({ display: "AAPL", market: "US", name: "Apple", tags: ["Core", "AI"] }),
|
||||||
expect.objectContaining({ display: "00700.HK", market: "HK", name: "Tencent" })
|
expect.objectContaining({ display: "00700.HK", market: "HK", name: "Tencent", tags: [] })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const reloaded = new WatchlistStore(path);
|
const reloaded = new WatchlistStore(path);
|
||||||
@@ -41,4 +41,23 @@ describe("WatchlistStore", () => {
|
|||||||
|
|
||||||
expect(await store.list()).toEqual([]);
|
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" }));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user