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); }); });