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>
180 lines
6.6 KiB
TypeScript
180 lines
6.6 KiB
TypeScript
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);
|
||
});
|
||
});
|