feat: bootstrap personal stock agent
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
IPADS_API_KEY=
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
.env
|
||||
.DS_Store
|
||||
*.log
|
||||
46
README.md
Normal file
46
README.md
Normal 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
12
index.html
Normal 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
3475
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
Normal file
35
package.json
Normal 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
143
src/agent/provider.ts
Normal 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
143
src/agent/report.ts
Normal 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
51
src/agent/symbols.ts
Normal 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" ? "港股" : "美股";
|
||||
}
|
||||
58
src/agent/watchlistStore.ts
Normal file
58
src/agent/watchlistStore.ts
Normal 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
250
src/client/main.tsx
Normal 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
332
src/client/styles.css
Normal 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
98
src/server/index.ts
Normal 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
18
src/server/ipadsConfig.ts
Normal 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
77
src/shared/types.ts
Normal 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
99
tests/provider.test.ts
Normal 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
76
tests/report.test.ts
Normal 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
19
tests/symbols.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
44
tests/watchlistStore.test.ts
Normal file
44
tests/watchlistStore.test.ts
Normal 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
20
tsconfig.json
Normal 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
11
vite.config.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user