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

1
.env.example Normal file
View File

@@ -0,0 +1 @@
IPADS_API_KEY=

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
data/
.env
.DS_Store
*.log

46
README.md Normal file
View File

@@ -0,0 +1,46 @@
# Personal Stock Agent
统一个人股票监控 MVP支持 A股、港股、美股自选股按日生成监控报告。数据接口固定使用 IPADS Responses API密钥从 `.env``IPADS_API_KEY` 读取。
## Run
```bash
npm install
npm run dev
```
Frontend: http://127.0.0.1:5173
Backend: http://127.0.0.1:8787
## IPADS API
配置文件:
```env
IPADS_API_KEY=your_api_key
```
固定服务端配置:
```text
base_url = "http://tianx.ipads-lab.se.sjtu.edu.cn:8319/v1"
wire_api = "responses"
env_key = "IPADS_API_KEY"
```
The backend calls:
```text
POST http://tianx.ipads-lab.se.sjtu.edu.cn:8319/v1/responses
Authorization: Bearer ${IPADS_API_KEY}
```
The response output text must be JSON matching the stock snapshot schema used by the agent. If `IPADS_API_KEY` is blank, the app uses deterministic demo data so the dashboard remains usable.
## Features
- Add/remove watchlist symbols such as `600519`, `000001.SZ`, `00700.HK`, `AAPL`, `MSFT.US`.
- Normalize symbols into A股、港股、美股 markets.
- Generate a daily report with earnings timing, peer stocks, valuation discount/premium, related news, and buy-watch/hold/avoid recommendations.
- Refreshes the cached daily report every 24 hours while the backend process is running.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Personal Stock Agent</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

3475
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "personal-stock-agent",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"",
"dev:server": "tsx watch src/server/index.ts",
"dev:client": "vite --host 127.0.0.1",
"test": "vitest run",
"build": "tsc --noEmit && vite build"
},
"dependencies": {
"@vitejs/plugin-react": "^5.0.0",
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^5.1.0",
"lucide-react": "^1.14.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"zod": "^4.1.5"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"concurrently": "^9.2.1",
"tsx": "^4.20.5",
"typescript": "^5.9.2",
"vite": "^7.1.3",
"vitest": "^3.2.4"
}
}

143
src/agent/provider.ts Normal file
View File

@@ -0,0 +1,143 @@
import { z } from "zod";
import type { MarketDataProvider, StockSnapshot, WatchStock } from "../shared/types";
type FetchLike = typeof fetch;
export const IPADS_BASE_URL = "http://tianx.ipads-lab.se.sjtu.edu.cn:8319/v1";
export const IPADS_WIRE_API = "responses";
export const IPADS_MODEL = "gpt-5.5";
const snapshotSchema = z.object({
price: z.number(),
currency: z.string().min(1),
pe: z.number(),
forwardPe: z.number().optional(),
revenueGrowth: z.number().optional(),
profitGrowth: z.number().optional(),
nextEarningsDate: z.string().optional(),
peers: z.array(z.object({ display: z.string(), pe: z.number() })).default([]),
news: z.array(z.object({ title: z.string(), url: z.string(), source: z.string().optional(), publishedAt: z.string().optional() })).default([])
});
const responsesOutputSchema = z.object({
output_text: z.string().optional(),
output: z
.array(
z.object({
content: z
.array(
z.object({
type: z.string().optional(),
text: z.string().optional()
})
)
.optional()
})
)
.optional()
});
export class IpadsResponsesProvider implements MarketDataProvider {
constructor(
private readonly apiKey: string,
private readonly fetchImpl: FetchLike = fetch
) {}
async snapshot(stock: WatchStock): Promise<StockSnapshot> {
const response = await this.fetchImpl(`${IPADS_BASE_URL}/${IPADS_WIRE_API}`, {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: IPADS_MODEL,
input: buildSnapshotPrompt(stock)
})
});
if (!response.ok) {
throw new Error(`IPADS Responses request failed: ${response.status}`);
}
return parseResponsesSnapshot(await response.json());
}
}
function buildSnapshotPrompt(stock: WatchStock): string {
return [
"Return only a valid JSON object for a stock monitoring snapshot.",
"Use this exact schema:",
'{"price": 100, "currency": "USD", "pe": 20, "forwardPe": 18, "revenueGrowth": 0.08, "profitGrowth": 0.05, "nextEarningsDate": "2026-05-13", "peers": [{"display": "MSFT", "pe": 28}], "news": [{"title": "Earnings preview", "url": "https://example.com/news"}]}',
"All numeric fields must be numbers, not null. If precise market data is unavailable, provide a conservative approximate monitoring snapshot and keep news empty.",
`Stock request: ${JSON.stringify({ symbol: stock.display, market: stock.market, name: stock.name })}`
].join("\n");
}
export class DemoMarketDataProvider implements MarketDataProvider {
async snapshot(stock: WatchStock): Promise<StockSnapshot> {
const seed = stock.display.split("").reduce((sum, char) => sum + char.charCodeAt(0), 0);
const marketCurrency = stock.market === "US" ? "USD" : stock.market === "HK" ? "HKD" : "CNY";
const pe = round(10 + (seed % 24), 2);
const peerBase = pe + ((seed % 2 === 0 ? 1 : -1) * (4 + (seed % 6)));
const nextEarnings = new Date(Date.UTC(2026, 4, 11 + (seed % 21))).toISOString().slice(0, 10);
return {
price: round(20 + (seed % 180) + (seed % 13) / 10, 2),
currency: marketCurrency,
pe,
forwardPe: round(pe * 0.92, 2),
revenueGrowth: round(((seed % 18) - 4) / 100, 4),
profitGrowth: round(((seed % 16) - 6) / 100, 4),
nextEarningsDate: nextEarnings,
peers: [
{ display: peerDisplay(stock, 1), pe: round(peerBase, 2) },
{ display: peerDisplay(stock, 2), pe: round(peerBase + 2.3, 2) }
],
news: [
{
title: `${stock.display} related sector signal updated`,
url: `https://example.local/news/${encodeURIComponent(stock.display)}`
}
]
};
}
}
export function createProvider(apiKey: string): MarketDataProvider {
if (apiKey.trim()) {
return new IpadsResponsesProvider(apiKey.trim());
}
return new DemoMarketDataProvider();
}
function parseResponsesSnapshot(payload: unknown): StockSnapshot {
const response = responsesOutputSchema.parse(payload);
const text = response.output_text ?? response.output?.flatMap((item) => item.content ?? []).find((content) => content.text)?.text;
if (!text) {
throw new Error("IPADS Responses payload did not include output text");
}
return snapshotSchema.parse(JSON.parse(stripJsonFence(text)));
}
function stripJsonFence(text: string): string {
const trimmed = text.trim();
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/);
return fenced?.[1] ?? trimmed;
}
function peerDisplay(stock: WatchStock, index: number): string {
if (stock.market === "HK") {
return `${String(9000 + index).padStart(5, "0")}.HK`;
}
if (stock.market === "CN") {
return `${600000 + index}.SS`;
}
return index === 1 ? "MSFT" : "GOOGL";
}
function round(value: number, digits: number): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}

143
src/agent/report.ts Normal file
View File

@@ -0,0 +1,143 @@
import type {
DailyReport,
DailyReportItem,
EarningsSignal,
Market,
MarketDataProvider,
Recommendation,
StockSnapshot,
ValuationSignal,
WatchStock
} from "../shared/types";
const DAY_MS = 24 * 60 * 60 * 1000;
export async function generateDailyReport(
watchlist: WatchStock[],
provider: MarketDataProvider,
now = new Date()
): Promise<DailyReport> {
const items = await Promise.all(
watchlist.map(async (stock) => buildReportItem(stock, await provider.snapshot(stock), now))
);
return {
generatedAt: now.toISOString(),
summary: buildSummary(items),
items: items.sort((left, right) => rankItem(right) - rankItem(left))
};
}
function buildReportItem(stock: WatchStock, snapshot: StockSnapshot, now: Date): DailyReportItem {
const earnings = analyzeEarnings(snapshot, now);
const valuation = analyzeValuation(snapshot);
const recommendation = recommend(snapshot, valuation, earnings);
return {
stock,
snapshot,
earnings,
valuation,
recommendation,
news: snapshot.news
};
}
function analyzeEarnings(snapshot: StockSnapshot, now: Date): EarningsSignal {
if (!snapshot.nextEarningsDate) {
return { status: "none" };
}
const date = new Date(`${snapshot.nextEarningsDate}T00:00:00.000Z`);
const start = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const daysUntil = Math.ceil((date.getTime() - start) / DAY_MS);
return {
status: daysUntil >= 0 && daysUntil <= 14 ? "soon" : "scheduled",
nextDate: snapshot.nextEarningsDate,
daysUntil
};
}
function analyzeValuation(snapshot: StockSnapshot): ValuationSignal {
const peerPes = snapshot.peers.map((peer) => peer.pe).filter((pe) => Number.isFinite(pe) && pe > 0);
if (!Number.isFinite(snapshot.pe) || snapshot.pe <= 0 || peerPes.length === 0) {
return { label: "unknown" };
}
const peerAveragePe = round(peerPes.reduce((sum, pe) => sum + pe, 0) / peerPes.length, 2);
const spreadPct = round((snapshot.pe - peerAveragePe) / peerAveragePe, 4);
const label = spreadPct <= -0.15 ? "discount" : spreadPct >= 0.15 ? "premium" : "fair";
return {
label,
stockPe: snapshot.pe,
peerAveragePe,
spreadPct
};
}
function recommend(snapshot: StockSnapshot, valuation: ValuationSignal, earnings: EarningsSignal): Recommendation {
const revenueGrowth = snapshot.revenueGrowth ?? 0;
const profitGrowth = snapshot.profitGrowth ?? 0;
const reasons: string[] = [];
if (valuation.label === "discount") {
reasons.push("估值低于可比股票均值");
}
if (valuation.label === "premium") {
reasons.push("估值高于可比股票均值");
}
if (revenueGrowth > 0) {
reasons.push("收入仍在增长");
}
if (profitGrowth > 0) {
reasons.push("利润仍在增长");
}
if (earnings.status === "soon") {
reasons.push("未来 14 天内有财报事件");
}
if (valuation.label === "discount" && revenueGrowth > 0 && profitGrowth >= 0) {
return { action: "buy_watch", confidence: 0.72, reasons };
}
if (valuation.label === "premium" && revenueGrowth < 0 && profitGrowth < 0) {
return { action: "avoid", confidence: 0.68, reasons };
}
return {
action: "hold",
confidence: earnings.status === "soon" ? 0.58 : 0.52,
reasons: reasons.length ? reasons : ["等待更明确的基本面或估值信号"]
};
}
function buildSummary(items: DailyReportItem[]): string {
const counts = new Map<Market, number>([
["CN", 0],
["HK", 0],
["US", 0]
]);
for (const item of items) {
counts.set(item.stock.market, (counts.get(item.stock.market) ?? 0) + 1);
}
const soon = items.filter((item) => item.earnings.status === "soon").length;
const discounts = items.filter((item) => item.valuation.label === "discount").length;
return `CN: ${counts.get("CN")}; HK: ${counts.get("HK")}; US: ${counts.get("US")}; earnings soon: ${soon}; valuation discounts: ${discounts}`;
}
function rankItem(item: DailyReportItem): number {
return (
(item.earnings.status === "soon" ? 4 : 0) +
(item.valuation.label === "discount" ? 3 : 0) +
(item.recommendation.action === "buy_watch" ? 2 : 0) +
item.news.length
);
}
function round(value: number, digits: number): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}

51
src/agent/symbols.ts Normal file
View File

@@ -0,0 +1,51 @@
import type { Market, NormalizedSymbol } from "../shared/types";
const CN_SUFFIXES = new Set(["SS", "SH", "SZ"]);
export function normalizeSymbol(input: string): NormalizedSymbol {
const raw = input.trim().toUpperCase();
if (!raw) {
throw new Error("Stock symbol is required");
}
const suffixMatch = raw.match(/^([A-Z0-9]+)\.([A-Z]{2})$/);
if (suffixMatch) {
const [, base, suffix] = suffixMatch;
if (CN_SUFFIXES.has(suffix)) {
return {
symbol: base,
market: "CN",
display: `${base}.${suffix === "SH" ? "SS" : suffix}`
};
}
if (suffix === "HK" && /^\d{4,5}$/.test(base)) {
return { symbol: base.padStart(5, "0"), market: "HK", display: `${base.padStart(5, "0")}.HK` };
}
if (suffix === "US" && /^[A-Z]{1,6}$/.test(base)) {
return { symbol: base, market: "US", display: base };
}
}
if (/^\d{6}$/.test(raw)) {
return { symbol: raw, market: "CN", display: `${raw}.${inferChinaExchange(raw)}` };
}
if (/^\d{4,5}$/.test(raw)) {
const symbol = raw.padStart(5, "0");
return { symbol, market: "HK", display: `${symbol}.HK` };
}
if (/^[A-Z]{1,6}$/.test(raw)) {
return { symbol: raw, market: "US", display: raw };
}
throw new Error("Unsupported stock symbol");
}
function inferChinaExchange(symbol: string): "SS" | "SZ" {
return symbol.startsWith("6") ? "SS" : "SZ";
}
export function marketLabel(market: Market): string {
return market === "CN" ? "A股" : market === "HK" ? "港股" : "美股";
}

View File

@@ -0,0 +1,58 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { normalizeSymbol } from "./symbols";
import type { WatchStock } from "../shared/types";
export class WatchlistStore {
constructor(private readonly path: string) {}
async list(): Promise<WatchStock[]> {
return this.read();
}
async add(symbolInput: string, name?: string): Promise<WatchStock> {
const normalized = normalizeSymbol(symbolInput);
const stocks = await this.read();
const existing = stocks.find((stock) => stock.display === normalized.display);
if (existing) {
return existing;
}
const stock: WatchStock = {
...normalized,
id: normalized.display,
name: name?.trim() || undefined,
addedAt: new Date().toISOString()
};
await this.write([...stocks, stock]);
return stock;
}
async remove(symbolInput: string): Promise<void> {
const normalized = normalizeSymbol(symbolInput);
const stocks = await this.read();
await this.write(stocks.filter((stock) => stock.display !== normalized.display));
}
private async read(): Promise<WatchStock[]> {
try {
const content = await readFile(this.path, "utf8");
const parsed = JSON.parse(content) as WatchStock[];
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
if (isMissingFile(error)) {
return [];
}
throw error;
}
}
private async write(stocks: WatchStock[]): Promise<void> {
await mkdir(dirname(this.path), { recursive: true });
await writeFile(this.path, `${JSON.stringify(stocks, null, 2)}\n`, "utf8");
}
}
function isMissingFile(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}

250
src/client/main.tsx Normal file
View File

@@ -0,0 +1,250 @@
import React, { useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import { Activity, Bell, CircleDollarSign, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
import type { DailyReport, WatchStock } from "../shared/types";
import "./styles.css";
interface HealthStatus {
providerConfigured: boolean;
baseUrl: string;
wireApi: string;
scheduler: string;
}
function App() {
const [watchlist, setWatchlist] = useState<WatchStock[]>([]);
const [report, setReport] = useState<DailyReport | undefined>();
const [health, setHealth] = useState<HealthStatus>({
providerConfigured: false,
baseUrl: "",
wireApi: "responses",
scheduler: "24h"
});
const [symbol, setSymbol] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
void loadAll();
}, []);
const marketCounts = useMemo(() => {
return {
CN: watchlist.filter((stock) => stock.market === "CN").length,
HK: watchlist.filter((stock) => stock.market === "HK").length,
US: watchlist.filter((stock) => stock.market === "US").length
};
}, [watchlist]);
async function loadAll() {
setLoading(true);
setError("");
try {
const [nextWatchlist, nextReport, nextHealth] = await Promise.all([
api<WatchStock[]>("/api/watchlist"),
api<DailyReport>("/api/report/daily"),
api<HealthStatus>("/api/health")
]);
setWatchlist(nextWatchlist);
setReport(nextReport);
setHealth(nextHealth);
} catch (nextError) {
setError(errorMessage(nextError));
} finally {
setLoading(false);
}
}
async function addStock(event: React.FormEvent) {
event.preventDefault();
setError("");
try {
await api<WatchStock>("/api/watchlist", {
method: "POST",
body: JSON.stringify({ symbol, name })
});
setSymbol("");
setName("");
await loadAll();
} catch (nextError) {
setError(errorMessage(nextError));
}
}
async function removeStock(display: string) {
await api(`/api/watchlist/${encodeURIComponent(display)}`, { method: "DELETE" });
await loadAll();
}
async function refreshReport() {
setLoading(true);
try {
setReport(await api<DailyReport>("/api/report/daily/refresh", { method: "POST" }));
} catch (nextError) {
setError(errorMessage(nextError));
} finally {
setLoading(false);
}
}
return (
<main className="shell">
<section className="topbar">
<div>
<p className="eyebrow">Personal Stock Agent</p>
<h1></h1>
</div>
<button className="iconText" onClick={refreshReport} disabled={loading} title="刷新日报">
<RefreshCw size={18} />
</button>
</section>
{error ? <div className="alert">{error}</div> : null}
<section className="metrics">
<Metric icon={<Activity size={20} />} label="自选股" value={watchlist.length} />
<Metric icon={<Bell size={20} />} label="A股 / 港股 / 美股" value={`${marketCounts.CN} / ${marketCounts.HK} / ${marketCounts.US}`} />
<Metric icon={<CircleDollarSign size={20} />} label="IPADS_API_KEY" value={health.providerConfigured ? "已配置" : "未配置"} />
<Metric icon={<Server size={20} />} label="wire API" value={health.wireApi} />
</section>
<section className="layout">
<div className="panel">
<div className="panelHeader">
<h2></h2>
</div>
<form className="stockForm" onSubmit={addStock}>
<input value={symbol} onChange={(event) => setSymbol(event.target.value)} placeholder="AAPL / 00700.HK / 600519" />
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="名称" />
<button className="iconButton" title="添加股票" aria-label="添加股票">
<Plus size={19} />
</button>
</form>
<div className="watchlist">
{watchlist.map((stock) => (
<div className="watchItem" key={stock.id}>
<div>
<strong>{stock.display}</strong>
<span>{stock.name || marketName(stock.market)}</span>
</div>
<button className="ghostIcon" onClick={() => removeStock(stock.display)} title="移除" aria-label={`移除 ${stock.display}`}>
<Trash2 size={17} />
</button>
</div>
))}
{watchlist.length === 0 ? <p className="empty"></p> : null}
</div>
</div>
<div className="panel">
<div className="panelHeader">
<h2>IPADS </h2>
</div>
<div className="configRows">
<span>base_url</span>
<strong>{health.baseUrl || "-"}</strong>
<span>env_key</span>
<strong>IPADS_API_KEY</strong>
<span></span>
<strong>{health.scheduler}</strong>
</div>
</div>
</section>
<section className="report">
<div className="panelHeader">
<div>
<h2></h2>
<p>{report?.summary ?? "暂无日报"}</p>
</div>
<span>{report ? new Date(report.generatedAt).toLocaleString() : ""}</span>
</div>
<div className="reportGrid">
{report?.items.map((item) => (
<article className="reportItem" key={item.stock.id}>
<div className="itemTitle">
<div>
<strong>{item.stock.display}</strong>
<span>{item.stock.name || marketName(item.stock.market)}</span>
</div>
<Badge tone={item.recommendation.action}>{actionLabel(item.recommendation.action)}</Badge>
</div>
<div className="quoteLine">
<span>{item.snapshot.price} {item.snapshot.currency}</span>
<span>PE {item.snapshot.pe}</span>
<span>{valuationLabel(item.valuation.label)}</span>
</div>
<p className="reason">{item.recommendation.reasons.join(" · ")}</p>
<div className="detailRows">
<span>: {item.earnings.nextDate ?? "未披露"}</span>
<span>: {item.valuation.peerAveragePe ?? "-"}</span>
<span>: {item.snapshot.peers.map((peer) => peer.display).join(", ") || "-"}</span>
</div>
{item.news[0] ? (
<a className="news" href={item.news[0].url} target="_blank" rel="noreferrer">
{item.news[0].title}
</a>
) : null}
</article>
))}
</div>
</section>
</main>
);
}
function Metric({ icon, label, value }: { icon: React.ReactNode; label: string; value: React.ReactNode }) {
return (
<div className="metric">
{icon}
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function Badge({ tone, children }: { tone: "buy_watch" | "hold" | "avoid"; children: React.ReactNode }) {
return <span className={`badge ${tone}`}>{children}</span>;
}
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {})
}
});
if (!response.ok) {
const body = (await response.json().catch(() => undefined)) as { error?: string } | undefined;
throw new Error(body?.error ?? `Request failed: ${response.status}`);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
function marketName(market: string) {
return market === "CN" ? "A股" : market === "HK" ? "港股" : "美股";
}
function actionLabel(action: string) {
return action === "buy_watch" ? "买入观察" : action === "avoid" ? "回避" : "持有";
}
function valuationLabel(label: string) {
return label === "discount" ? "估值折价" : label === "premium" ? "估值溢价" : label === "fair" ? "估值合理" : "估值未知";
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Unknown error";
}
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

332
src/client/styles.css Normal file
View File

@@ -0,0 +1,332 @@
:root {
color: #18202c;
background: #eef1f4;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.shell {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 40px;
}
.topbar,
.layout,
.metrics,
.reportGrid,
.itemTitle,
.panelHeader,
.watchItem,
.quoteLine {
display: grid;
gap: 16px;
}
.topbar {
grid-template-columns: 1fr auto;
align-items: center;
margin-bottom: 22px;
}
.eyebrow {
margin: 0 0 4px;
color: #596579;
font-size: 13px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: clamp(30px, 4vw, 48px);
line-height: 1.05;
letter-spacing: 0;
}
h2 {
font-size: 18px;
letter-spacing: 0;
}
.iconText {
border: 0;
border-radius: 8px;
background: #184c8f;
color: #fff;
min-height: 42px;
padding: 0 16px;
font-weight: 700;
}
.iconText {
display: inline-flex;
align-items: center;
gap: 8px;
}
.iconButton,
.ghostIcon {
width: 42px;
height: 42px;
display: inline-grid;
place-items: center;
border-radius: 8px;
}
.iconButton {
border: 0;
background: #1f7a5c;
color: #fff;
}
.ghostIcon {
border: 1px solid #d6dce5;
background: #fff;
color: #596579;
}
.alert {
border-left: 4px solid #bd3b3b;
background: #fff;
color: #8f2020;
padding: 12px 14px;
margin-bottom: 18px;
border-radius: 8px;
}
.metrics {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 18px;
}
.metric,
.panel,
.report,
.reportItem {
background: #fff;
border: 1px solid #dfe4ea;
border-radius: 8px;
}
.metric {
min-height: 104px;
padding: 18px;
display: grid;
align-content: space-between;
}
.metric svg {
color: #b45f24;
}
.metric span {
color: #667085;
font-size: 13px;
}
.metric strong {
font-size: 24px;
}
.layout {
grid-template-columns: 1.15fr 0.85fr;
margin-bottom: 18px;
}
.panel,
.report {
padding: 18px;
}
.panelHeader {
grid-template-columns: 1fr auto;
align-items: start;
margin-bottom: 16px;
}
.panelHeader p,
.panelHeader span,
.reason,
.empty,
.detailRows,
.watchItem span {
color: #667085;
font-size: 13px;
}
.stockForm {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 10px;
margin-bottom: 14px;
}
.configRows {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 10px 12px;
align-items: baseline;
}
.configRows span {
color: #465366;
font-size: 13px;
}
.configRows strong {
min-width: 0;
overflow-wrap: anywhere;
font-size: 14px;
}
input {
width: 100%;
min-height: 42px;
border: 1px solid #cad2dd;
border-radius: 8px;
padding: 0 12px;
color: #18202c;
background: #f9fafb;
}
input:focus {
outline: 2px solid #8bb8e8;
border-color: #184c8f;
}
.watchlist {
display: grid;
gap: 10px;
}
.watchItem {
grid-template-columns: 1fr auto;
align-items: center;
padding: 10px 0;
border-top: 1px solid #edf0f3;
}
.watchItem div,
.itemTitle div {
display: grid;
gap: 3px;
}
.reportGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.reportItem {
padding: 16px;
}
.itemTitle {
grid-template-columns: 1fr auto;
align-items: center;
margin-bottom: 14px;
}
.badge {
border-radius: 999px;
padding: 5px 10px;
font-size: 12px;
font-weight: 800;
}
.buy_watch {
color: #0f5c43;
background: #dff4eb;
}
.hold {
color: #684513;
background: #f8ead2;
}
.avoid {
color: #8f2020;
background: #f7dddd;
}
.quoteLine {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 10px;
}
.quoteLine span {
border-radius: 8px;
background: #f2f5f8;
min-height: 34px;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
}
.reason {
min-height: 38px;
line-height: 1.45;
}
.detailRows {
display: grid;
gap: 6px;
margin: 12px 0;
}
.news {
display: block;
color: #184c8f;
font-size: 13px;
font-weight: 700;
text-decoration: none;
overflow-wrap: anywhere;
}
@media (max-width: 860px) {
.shell {
width: min(100% - 20px, 720px);
padding-top: 18px;
}
.topbar,
.layout,
.reportGrid,
.metrics {
grid-template-columns: 1fr;
}
.stockForm {
grid-template-columns: 1fr auto;
}
.stockForm input:first-child {
grid-column: 1 / -1;
}
}

98
src/server/index.ts Normal file
View File

@@ -0,0 +1,98 @@
import "dotenv/config";
import cors from "cors";
import express from "express";
import { join } from "node:path";
import { generateDailyReport } from "../agent/report";
import { createProvider } from "../agent/provider";
import { WatchlistStore } from "../agent/watchlistStore";
import { loadIpadsConfig } from "./ipadsConfig";
const port = Number(process.env.PORT ?? 8787);
const dataDir = process.env.STOCK_AGENT_DATA_DIR ?? join(process.cwd(), "data");
const watchlistStore = new WatchlistStore(join(dataDir, "watchlist.json"));
const app = express();
let cachedReportUpdatedAt = 0;
let cachedReport: Awaited<ReturnType<typeof generateDailyReport>> | undefined;
app.use(cors());
app.use(express.json());
app.get("/api/health", async (_request, response) => {
const config = loadIpadsConfig();
response.json({
ok: true,
providerConfigured: config.apiKeyConfigured,
baseUrl: config.baseUrl,
wireApi: config.wireApi,
scheduler: "24h"
});
});
app.get("/api/watchlist", async (_request, response) => {
response.json(await watchlistStore.list());
});
app.post("/api/watchlist", async (request, response, next) => {
try {
response.status(201).json(await watchlistStore.add(String(request.body?.symbol ?? ""), request.body?.name));
cachedReport = undefined;
} catch (error) {
next(error);
}
});
app.delete("/api/watchlist/:symbol", async (request, response, next) => {
try {
await watchlistStore.remove(request.params.symbol);
cachedReport = undefined;
response.status(204).end();
} catch (error) {
next(error);
}
});
app.get("/api/report/daily", async (_request, response, next) => {
try {
response.json(await getDailyReport());
} catch (error) {
next(error);
}
});
app.post("/api/report/daily/refresh", async (_request, response, next) => {
try {
cachedReport = undefined;
response.json(await getDailyReport());
} catch (error) {
next(error);
}
});
app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
const message = error instanceof Error ? error.message : "Unknown server error";
response.status(400).json({ error: message });
});
setInterval(() => {
void getDailyReport().catch((error) => {
console.error("daily report refresh failed", error);
});
}, 24 * 60 * 60 * 1000).unref();
app.listen(port, "127.0.0.1", () => {
console.log(`stock agent api listening at http://127.0.0.1:${port}`);
});
async function getDailyReport() {
const now = Date.now();
if (cachedReport && now - cachedReportUpdatedAt < 24 * 60 * 60 * 1000) {
return cachedReport;
}
const watchlist = await watchlistStore.list();
cachedReport = await generateDailyReport(watchlist, createProvider(loadIpadsConfig().apiKey), new Date());
cachedReportUpdatedAt = now;
return cachedReport;
}

18
src/server/ipadsConfig.ts Normal file
View File

@@ -0,0 +1,18 @@
import { IPADS_BASE_URL, IPADS_WIRE_API } from "../agent/provider";
export interface IpadsConfig {
baseUrl: typeof IPADS_BASE_URL;
wireApi: typeof IPADS_WIRE_API;
apiKey: string;
apiKeyConfigured: boolean;
}
export function loadIpadsConfig(env: NodeJS.ProcessEnv = process.env): IpadsConfig {
const apiKey = (env.IPADS_API_KEY ?? "").trim();
return {
baseUrl: IPADS_BASE_URL,
wireApi: IPADS_WIRE_API,
apiKey,
apiKeyConfigured: Boolean(apiKey)
};
}

77
src/shared/types.ts Normal file
View File

@@ -0,0 +1,77 @@
export type Market = "CN" | "HK" | "US";
export type RecommendationAction = "buy_watch" | "hold" | "avoid";
export interface NormalizedSymbol {
symbol: string;
market: Market;
display: string;
}
export interface WatchStock extends NormalizedSymbol {
id: string;
name?: string;
addedAt: string;
}
export interface RelatedNews {
title: string;
url: string;
source?: string;
publishedAt?: string;
}
export interface PeerValuation {
display: string;
pe: number;
}
export interface StockSnapshot {
price: number;
currency: string;
pe: number;
forwardPe?: number;
revenueGrowth?: number;
profitGrowth?: number;
nextEarningsDate?: string;
peers: PeerValuation[];
news: RelatedNews[];
}
export interface MarketDataProvider {
snapshot(stock: WatchStock): Promise<StockSnapshot>;
}
export interface EarningsSignal {
status: "none" | "soon" | "scheduled";
nextDate?: string;
daysUntil?: number;
}
export interface ValuationSignal {
label: "discount" | "fair" | "premium" | "unknown";
stockPe?: number;
peerAveragePe?: number;
spreadPct?: number;
}
export interface Recommendation {
action: RecommendationAction;
confidence: number;
reasons: string[];
}
export interface DailyReportItem {
stock: WatchStock;
snapshot: StockSnapshot;
earnings: EarningsSignal;
valuation: ValuationSignal;
recommendation: Recommendation;
news: RelatedNews[];
}
export interface DailyReport {
generatedAt: string;
summary: string;
items: DailyReportItem[];
}

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([]);
});
});

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "tests", "vite.config.ts"]
}

11
vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://127.0.0.1:8787"
}
}
});