Files
stock-agent/tests/provider.test.ts
Gahow Wang 870b9cb2b1 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>
2026-05-19 23:20:42 +08:00

218 lines
7.2 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { DemoMarketDataProvider, IPADS_BASE_URL, IPADS_WIRE_API, IpadsResponsesProvider } from "../src/agent/provider";
import { loadIpadsConfig } from "../src/server/ipadsConfig";
import type { WatchStock } from "../src/shared/types";
const stock: WatchStock = {
id: "AAPL",
symbol: "AAPL",
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 for agent quant analysis", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
output: [
{
type: "message",
role: "assistant",
content: [
{
type: "output_text",
text: JSON.stringify({
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: []
})
}
]
}
]
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
});
const provider = new IpadsResponsesProvider("secret", fetchImpl);
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}`,
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: "Bearer secret",
"Content-Type": "application/json"
}),
body: expect.stringContaining("\"model\":\"gpt-5.5\"")
})
);
const calls = fetchImpl.mock.calls as unknown as [string, RequestInit][];
const requestBody = JSON.parse(String(calls[0]?.[1].body));
expect(requestBody.input).toContain("\"symbol\":\"AAPL\"");
expect(typeof requestBody.input).toBe("string");
expect(requestBody).not.toHaveProperty("metadata");
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();
});
});
describe("loadIpadsConfig", () => {
it("reads IPADS_API_KEY from an env object and keeps fixed URL and wire API", () => {
expect(loadIpadsConfig({ IPADS_API_KEY: "secret" })).toEqual({
baseUrl: IPADS_BASE_URL,
wireApi: IPADS_WIRE_API,
apiKey: "secret",
apiKeyConfigured: true
});
});
it("reports an unconfigured API key when IPADS_API_KEY is blank", () => {
expect(loadIpadsConfig({ IPADS_API_KEY: " " })).toEqual({
baseUrl: IPADS_BASE_URL,
wireApi: IPADS_WIRE_API,
apiKey: "",
apiKeyConfigured: false
});
});
});
describe("DemoMarketDataProvider", () => {
it("returns deterministic snapshots when no provider URL is configured", async () => {
const provider = new DemoMarketDataProvider();
await expect(provider.snapshot(stock)).resolves.toEqual(
expect.objectContaining({
currency: "USD",
peers: expect.any(Array),
news: expect.any(Array)
})
);
});
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)
})
})
);
});
});