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>
218 lines
7.2 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
);
|
|
});
|
|
});
|