feat: bootstrap personal stock agent

This commit is contained in:
2026-05-11 21:10:55 +08:00
commit 437565c16c
21 changed files with 5014 additions and 0 deletions

99
tests/provider.test.ts Normal file
View File

@@ -0,0 +1,99 @@
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",
addedAt: "2026-05-11T00:00:00.000Z"
};
describe("IpadsResponsesProvider", () => {
it("uses the fixed IPADS Responses API endpoint with IPADS_API_KEY", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
output: [
{
type: "message",
role: "assistant",
content: [
{
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" }]
})
}
]
}
]
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
});
const provider = new IpadsResponsesProvider("secret", fetchImpl);
const snapshot = await provider.snapshot(stock);
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(snapshot.news[0]?.title).toBe("Apple earnings preview");
});
});
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)
})
);
});
});

76
tests/report.test.ts Normal file
View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { generateDailyReport } from "../src/agent/report";
import type { MarketDataProvider, WatchStock } from "../src/shared/types";
const watchlist: WatchStock[] = [
{
id: "AAPL",
symbol: "AAPL",
display: "AAPL",
market: "US",
name: "Apple",
addedAt: "2026-05-11T00:00:00.000Z"
},
{
id: "00700.HK",
symbol: "00700",
display: "00700.HK",
market: "HK",
name: "Tencent",
addedAt: "2026-05-11T00:00:00.000Z"
}
];
const provider: MarketDataProvider = {
async snapshot(stock) {
if (stock.display === "AAPL") {
return {
price: 100,
currency: "USD",
pe: 12,
forwardPe: 11,
revenueGrowth: 0.08,
profitGrowth: 0.05,
nextEarningsDate: "2026-05-13",
peers: [
{ display: "MSFT", pe: 28 },
{ display: "GOOGL", pe: 24 }
],
news: [{ title: "Apple supplier raises shipment guidance", url: "https://example.test/aapl" }]
};
}
return {
price: 380,
currency: "HKD",
pe: 22,
forwardPe: 20,
revenueGrowth: 0.02,
profitGrowth: -0.01,
nextEarningsDate: "2026-06-30",
peers: [{ display: "9988.HK", pe: 18 }],
news: []
};
}
};
describe("generateDailyReport", () => {
it("ranks earnings, related news, valuation mismatch, and buy advice across markets", async () => {
const report = await generateDailyReport(watchlist, provider, new Date("2026-05-11T00:00:00.000Z"));
expect(report.generatedAt).toBe("2026-05-11T00:00:00.000Z");
expect(report.items).toHaveLength(2);
expect(report.summary).toContain("US: 1");
expect(report.summary).toContain("HK: 1");
const apple = report.items.find((item) => item.stock.display === "AAPL");
expect(apple?.earnings.status).toBe("soon");
expect(apple?.valuation.label).toBe("discount");
expect(apple?.recommendation.action).toBe("buy_watch");
expect(apple?.news[0]?.title).toContain("supplier");
const tencent = report.items.find((item) => item.stock.display === "00700.HK");
expect(tencent?.valuation.label).toBe("premium");
expect(tencent?.recommendation.action).toBe("hold");
});
});

19
tests/symbols.test.ts Normal file
View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { normalizeSymbol } from "../src/agent/symbols";
describe("normalizeSymbol", () => {
it.each([
["600519", { symbol: "600519", market: "CN", display: "600519.SS" }],
["000001.SZ", { symbol: "000001", market: "CN", display: "000001.SZ" }],
["00700.hk", { symbol: "00700", market: "HK", display: "00700.HK" }],
["AAPL", { symbol: "AAPL", market: "US", display: "AAPL" }],
["msft.us", { symbol: "MSFT", market: "US", display: "MSFT" }]
])("normalizes %s", (input, expected) => {
expect(normalizeSymbol(input)).toEqual(expected);
});
it("rejects empty and unsupported symbols", () => {
expect(() => normalizeSymbol("")).toThrow("Stock symbol is required");
expect(() => normalizeSymbol("!!!")).toThrow("Unsupported stock symbol");
});
});

View File

@@ -0,0 +1,44 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { WatchlistStore } from "../src/agent/watchlistStore";
let tempDir: string | undefined;
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { force: true, recursive: true });
tempDir = undefined;
}
});
describe("WatchlistStore", () => {
it("adds normalized stocks once and persists them", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-"));
const path = join(tempDir, "watchlist.json");
const store = new WatchlistStore(path);
await store.add("aapl", "Apple");
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" })
]);
const reloaded = new WatchlistStore(path);
expect(await reloaded.list()).toHaveLength(2);
});
it("removes stocks by any supported symbol form", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-"));
const store = new WatchlistStore(join(tempDir, "watchlist.json"));
await store.add("600519", "Kweichow Moutai");
await store.remove("600519.SS");
expect(await store.list()).toEqual([]);
});
});