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:
2026-05-19 23:20:42 +08:00
parent 30f6cd84a8
commit a4a2cb2ccf
12 changed files with 2187 additions and 32 deletions

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