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:
@@ -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)
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
179
web/tests/stockDataCache.test.ts
Normal file
179
web/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([
|
||||
["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" }]
|
||||
|
||||
@@ -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" }));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user