Merge stock-agent history into web/

stock-agent (the web UI and API that shell out to bridge.py) now lives in
this repository under web/, with its full commit history rewritten into
that prefix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 11:39:31 +08:00
30 changed files with 12748 additions and 0 deletions

1
web/.env.example Normal file
View File

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

7
web/.gitignore vendored Normal file
View File

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

70
web/README.md Normal file
View File

@@ -0,0 +1,70 @@
# 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
LAN access:
```bash
HOST=0.0.0.0 CLIENT_PORT=5174 PORT=8787 npm run dev
```
Open `http://gahow-pc.ipads-lab.se.sjtu.edu.cn:5174` from another device on the same network.
## 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.
- View each watchlist stock with locally cached D/W/M/Y K-line chart data, zoom controls, MA5/MA20/MA60, RSI14, MACD, volume, and cached financial statements. US K-line and quote/research fields use Nasdaq as the fixed data source and target at least 5 years of verified daily OHLCV history when available.
- Cache related market context such as sector, liquidity, volatility, drawdown, relative strength, capital flow, sentiment, and risk flags.
- Run an agent-backed quantitative analysis across technical, valuation, growth, quality, liquidity, sentiment, and risk factors to classify each stock as buy-watch/hold/avoid.
- Add tags to every watchlist stock and filter the watchlist by tag.
- In configured provider mode, prompts require verified data and do not permit approximate or fabricated values; missing data is surfaced instead of being filled with synthetic values.
- K-line cache updates incrementally: once 5 years of daily bars exist locally, refreshes request only the latest missing window and merge by date.
## Local cache
Persistent app data defaults to `./data` and can be moved with:
```env
STOCK_AGENT_DATA_DIR=/path/to/stock-agent-data
```
The backend stores watchlist data in `watchlist.json` and per-stock cached snapshot/K-line/financial data under `cache/`.

12
web/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 Monitor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/client/main.tsx"></script>
</body>
</html>

3491
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
web/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"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",
"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",
"lightweight-charts": "^5.2.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"
}
}

14
web/scripts/start-server.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
export PATH="/home/gahow/.local/bin:/home/gahow/.nvm/versions/node/v24.13.1/bin:$PATH"
export HOST="127.0.0.1"
export PORT="${PORT:-8787}"
# Node's built-in fetch does not read HTTPS_PROXY unless this opt-in is enabled.
# The configured Nasdaq/Yahoo data sources require the host's local proxy.
export NODE_USE_ENV_PROXY="${NODE_USE_ENV_PROXY:-1}"
exec npx tsx src/server/index.ts

11
web/scripts/start-vite.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
export PATH="/home/gahow/.nvm/versions/node/v24.13.1/bin:$PATH"
# The public HTTPS entrypoint is nginx on :5443. Keep Vite as a localhost-only
# upstream so the development server is not exposed as a second LAN entrypoint.
exec npx vite --host 127.0.0.1 --port 5174

119
web/src/agent/indicators.ts Normal file
View File

@@ -0,0 +1,119 @@
import type { IndicatorPoint, PriceCandle } from "../shared/types";
export function calculateIndicators(candles: PriceCandle[]): IndicatorPoint[] {
const closes = candles.map((candle) => candle.close);
const ema12 = ema(closes, 12);
const ema26 = ema(closes, 26);
const macdLine = closes.map((_, index) => valueOrUndefined(ema12[index], ema26[index], (fast, slow) => fast - slow));
const signalLine = ema(macdLine, 9);
const rsi = rsi14(closes);
return candles.map((candle, index) => {
const macd = macdLine[index];
const macdSignal = signalLine[index];
return {
date: candle.date,
ma5: movingAverage(closes, index, 5),
ma20: movingAverage(closes, index, 20),
ma60: movingAverage(closes, index, 60),
ma120: movingAverage(closes, index, 120),
ma200: movingAverage(closes, index, 200),
rsi14: rsi[index],
macd,
macdSignal,
macdHistogram: valueOrUndefined(macd, macdSignal, (line, signal) => line - signal)
};
});
}
function movingAverage(values: number[], index: number, window: number): number | undefined {
if (index + 1 < window) {
return undefined;
}
const slice = values.slice(index + 1 - window, index + 1);
return round(slice.reduce((sum, value) => sum + value, 0) / window, 4);
}
function ema(values: Array<number | undefined>, window: number): Array<number | undefined> {
const result: Array<number | undefined> = [];
const multiplier = 2 / (window + 1);
let previous: number | undefined;
let seedValues: number[] = [];
for (const value of values) {
if (value === undefined || !Number.isFinite(value)) {
result.push(undefined);
continue;
}
if (previous === undefined) {
seedValues.push(value);
if (seedValues.length < window) {
result.push(undefined);
continue;
}
previous = seedValues.reduce((sum, item) => sum + item, 0) / window;
result.push(round(previous, 4));
continue;
}
previous = value * multiplier + previous * (1 - multiplier);
result.push(round(previous, 4));
}
return result;
}
function rsi14(closes: number[]): Array<number | undefined> {
const result: Array<number | undefined> = [undefined];
let gains = 0;
let losses = 0;
for (let index = 1; index < closes.length; index += 1) {
const change = closes[index] - closes[index - 1];
const gain = Math.max(0, change);
const loss = Math.max(0, -change);
if (index <= 14) {
gains += gain;
losses += loss;
if (index === 14) {
gains /= 14;
losses /= 14;
result.push(rsiValue(gains, losses));
} else {
result.push(undefined);
}
continue;
}
gains = (gains * 13 + gain) / 14;
losses = (losses * 13 + loss) / 14;
result.push(rsiValue(gains, losses));
}
return result;
}
function rsiValue(gain: number, loss: number): number {
if (loss === 0) {
return 100;
}
return round(100 - 100 / (1 + gain / loss), 4);
}
function valueOrUndefined(
left: number | undefined,
right: number | undefined,
calculate: (left: number, right: number) => number
): number | undefined {
if (left === undefined || right === undefined) {
return undefined;
}
return round(calculate(left, right), 4);
}
function round(value: number, digits: number): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}

1240
web/src/agent/provider.ts Normal file

File diff suppressed because it is too large Load Diff

144
web/src/agent/report.ts Normal file
View File

@@ -0,0 +1,144 @@
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 stockPe = snapshot.pe;
const peerPes = snapshot.peers.map((peer) => peer.pe).filter((pe) => Number.isFinite(pe) && pe > 0);
if (!Number.isFinite(stockPe) || stockPe === undefined || stockPe <= 0 || peerPes.length === 0) {
return { label: "unknown" };
}
const peerAveragePe = round(peerPes.reduce((sum, pe) => sum + pe, 0) / peerPes.length, 2);
const spreadPct = round((stockPe - peerAveragePe) / peerAveragePe, 4);
const label = spreadPct <= -0.15 ? "discount" : spreadPct >= 0.15 ? "premium" : "fair";
return {
label,
stockPe,
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;
}

View File

@@ -0,0 +1,380 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { calculateIndicators } from "./indicators";
import type {
ChartPeriod,
FinancialStatement,
MarketDataProvider,
PriceCandle,
QuantAnalysis,
RelatedStockData,
StockChartData,
StockFundamentalsData,
StockQuantAnalysis,
StockRelatedData,
StockSnapshot,
WatchStock
} from "../shared/types";
interface CachedEntry<T> {
cachedAt: string;
data: T;
}
interface StockCacheFile {
snapshot?: CachedEntry<StockSnapshot>;
candles?: CachedEntry<PriceCandle[]>;
candlesWindowYears?: number;
candlesProviderVersion?: number;
fundamentals?: CachedEntry<FinancialStatement[]>;
relatedData?: CachedEntry<RelatedStockData>;
quantAnalysis?: CachedEntry<QuantAnalysis> & { version?: number };
}
const SNAPSHOT_TTL_MS = 24 * 60 * 60 * 1000;
const CANDLES_TTL_MS = 24 * 60 * 60 * 1000;
const FUNDAMENTALS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const RELATED_TTL_MS = 24 * 60 * 60 * 1000;
const ANALYSIS_VERSION = 4;
const CANDLES_WINDOW_YEARS = 10;
const CANDLES_PROVIDER_VERSION = 2;
export class StockDataCache {
constructor(
private readonly cacheDir: string,
private readonly provider: MarketDataProvider,
private readonly now: () => Date = () => new Date()
) {}
async snapshot(stock: WatchStock, force = false): Promise<StockSnapshot> {
const cache = await this.read(stock);
if (!force && cache.snapshot && !isExpired(cache.snapshot.cachedAt, SNAPSHOT_TTL_MS, this.now())) {
return cache.snapshot.data;
}
const next = await this.provider.snapshot(stock);
await this.write(stock, { ...cache, snapshot: { cachedAt: this.now().toISOString(), data: next } });
return next;
}
async chart(stock: WatchStock, period: ChartPeriod = "D", force = false): Promise<StockChartData> {
const cache = await this.read(stock);
const existing = cache.candles;
let entry: CachedEntry<PriceCandle[]>;
try {
if (!existing || force || cache.candlesWindowYears !== CANDLES_WINDOW_YEARS || cache.candlesProviderVersion !== CANDLES_PROVIDER_VERSION) {
entry = { cachedAt: this.now().toISOString(), data: await this.provider.candles(stock) };
} else if (isExpired(existing.cachedAt, CANDLES_TTL_MS, this.now())) {
entry = existing;
void this.refreshCandles(stock, existing);
} else {
entry = existing;
}
} catch (error) {
if (existing) {
entry = existing;
} else {
throw error;
}
}
if (entry !== cache.candles) {
await this.write(stock, { ...cache, candles: entry, candlesWindowYears: CANDLES_WINDOW_YEARS, candlesProviderVersion: CANDLES_PROVIDER_VERSION });
}
const candles = aggregateCandles(entry.data, period);
return {
stock,
period,
candles,
indicators: calculateIndicators(candles),
cachedAt: entry.cachedAt
};
}
private async refreshCandles(stock: WatchStock, existing: CachedEntry<PriceCandle[]>): Promise<void> {
try {
const cache = await this.read(stock);
if (cache.candles?.cachedAt !== existing.cachedAt) {
return;
}
const startDate = previousCalendarDate(existing.data[existing.data.length - 1]?.date);
const incremental = startDate ? await this.provider.candles(stock, startDate) : await this.provider.candles(stock);
const entry = {
cachedAt: this.now().toISOString(),
data: incremental.length ? mergeCandles(existing.data, incremental) : existing.data
};
await this.write(stock, { ...cache, candles: entry, candlesWindowYears: CANDLES_WINDOW_YEARS, candlesProviderVersion: CANDLES_PROVIDER_VERSION });
} catch {
// Keep stale cache when the upstream market data source is unavailable.
}
}
async refreshChart(stock: WatchStock, period: ChartPeriod = "D"): Promise<StockChartData> {
const cache = await this.read(stock);
const existing = cache.candles;
let entry: CachedEntry<PriceCandle[]>;
if (existing && cache.candlesWindowYears === CANDLES_WINDOW_YEARS && cache.candlesProviderVersion === CANDLES_PROVIDER_VERSION) {
const startDate = previousCalendarDate(existing.data[existing.data.length - 1]?.date);
const incremental = startDate ? await this.provider.candles(stock, startDate) : await this.provider.candles(stock);
entry = {
cachedAt: this.now().toISOString(),
data: incremental.length ? mergeCandles(existing.data, incremental) : existing.data
};
} else {
entry = { cachedAt: this.now().toISOString(), data: await this.provider.candles(stock) };
}
await this.write(stock, { ...cache, candles: entry, candlesWindowYears: CANDLES_WINDOW_YEARS, candlesProviderVersion: CANDLES_PROVIDER_VERSION });
const candles = aggregateCandles(entry.data, period);
return {
stock,
period,
candles,
indicators: calculateIndicators(candles),
cachedAt: entry.cachedAt
};
}
async fundamentals(stock: WatchStock, force = false): Promise<StockFundamentalsData> {
const cache = await this.read(stock);
let entry: CachedEntry<FinancialStatement[]>;
try {
entry =
!force && cache.fundamentals
? cache.fundamentals
: { cachedAt: this.now().toISOString(), data: await this.provider.fundamentals(stock) };
} catch {
entry = cache.fundamentals ?? { cachedAt: this.now().toISOString(), data: [] };
}
if (entry !== cache.fundamentals) {
await this.write(stock, { ...cache, fundamentals: entry });
}
return {
stock,
statements: entry.data,
cachedAt: entry.cachedAt
};
}
async relatedData(stock: WatchStock, force = false): Promise<StockRelatedData> {
const cache = await this.read(stock);
let entry: CachedEntry<RelatedStockData>;
try {
entry =
!force && cache.relatedData && hasResearchSchema(cache.relatedData.data)
? cache.relatedData
: { cachedAt: this.now().toISOString(), data: await this.provider.relatedData(stock) };
} catch {
entry = cache.relatedData ?? {
cachedAt: this.now().toISOString(),
data: {
riskFlags: ["相关市场数据源暂不可用。"],
sources: []
}
};
}
if (entry !== cache.relatedData) {
await this.write(stock, { ...cache, relatedData: entry });
}
return {
stock,
data: entry.data,
cachedAt: entry.cachedAt
};
}
async quantAnalysis(stock: WatchStock, force = false): Promise<StockQuantAnalysis> {
const cache = await this.read(stock);
const hasTodayAnalysis =
cache.quantAnalysis?.version === ANALYSIS_VERSION && isSameShanghaiDate(cache.quantAnalysis.cachedAt, this.now());
if (!force && cache.quantAnalysis?.version === ANALYSIS_VERSION) {
return {
stock,
analysis: cache.quantAnalysis.data,
generatedAt: cache.quantAnalysis.cachedAt,
cachedAt: cache.quantAnalysis.cachedAt
};
}
const refreshSourceData = force || !hasTodayAnalysis;
const snapshot = await this.snapshot(stock, refreshSourceData);
const chart = await this.chart(stock, "D", refreshSourceData);
const fundamentals = await this.fundamentals(stock, refreshSourceData).catch(() => ({ stock, statements: [], cachedAt: this.now().toISOString() }));
const related = await this.relatedData(stock, refreshSourceData).catch(() => ({
stock,
data: { riskFlags: ["相关市场数据源暂不可用。"], sources: [] },
cachedAt: this.now().toISOString()
}));
const generatedAt = this.now().toISOString();
const analysis = await this.provider.quantAnalysis({
stock,
snapshot,
candles: chart.candles,
indicators: chart.indicators,
fundamentals: fundamentals.statements,
relatedData: related.data
});
const entry = { cachedAt: generatedAt, data: analysis, version: ANALYSIS_VERSION };
await this.write(stock, { ...(await this.read(stock)), quantAnalysis: entry });
return {
stock,
analysis,
generatedAt,
cachedAt: entry.cachedAt
};
}
async analysisNeedsRefresh(stock: WatchStock): Promise<boolean> {
const cache = await this.read(stock);
return cache.quantAnalysis?.version !== ANALYSIS_VERSION || !isSameShanghaiDate(cache.quantAnalysis.cachedAt, this.now());
}
async hasAnalysis(stock: WatchStock): Promise<boolean> {
const cache = await this.read(stock);
return cache.quantAnalysis?.version === ANALYSIS_VERSION;
}
placeholderAnalysis(stock: WatchStock): StockQuantAnalysis {
const generatedAt = this.now().toISOString();
return {
stock,
generatedAt,
cachedAt: generatedAt,
analysis: {
action: "hold",
score: 50,
confidence: 0.1,
horizon: "后台生成中",
summary: "本地暂无 Agent 分析缓存,已在后台生成最新量化分析。当前先展示缓存数据,避免切换自选股时阻塞界面。",
factors: [
{ name: "缓存状态", score: 50, weight: 1, evidence: "等待后台 Agent 分析写入本地缓存。" },
{ name: "技术面", score: 50, weight: 0, evidence: "K线和指标可先独立查看。" },
{ name: "风险控制", score: 50, weight: 0, evidence: "完整交易计划将在后台分析完成后更新。" }
],
tradePlan: {
direction: "wait",
rationale: "后台分析完成前不生成买入、卖出或止损价。"
},
marketContext: ["Agent 分析正在后台生成。"],
riskNotes: ["该占位结果只表示后台任务状态,不代表实际投资判断。"]
}
};
}
private async read(stock: WatchStock): Promise<StockCacheFile> {
try {
return JSON.parse(await readFile(this.pathFor(stock), "utf8")) as StockCacheFile;
} catch (error) {
if (isMissingFile(error)) {
return {};
}
throw error;
}
}
private async write(stock: WatchStock, cache: StockCacheFile): Promise<void> {
await mkdir(this.cacheDir, { recursive: true });
await writeFile(this.pathFor(stock), `${JSON.stringify(cache, null, 2)}\n`, "utf8");
}
private pathFor(stock: WatchStock): string {
return join(this.cacheDir, `${stock.display.replace(/[^A-Z0-9]/g, "_")}.json`);
}
}
function isExpired(cachedAt: string, ttlMs: number, now: Date): boolean {
const cachedTime = new Date(cachedAt).getTime();
return !Number.isFinite(cachedTime) || now.getTime() - cachedTime > ttlMs;
}
function isMissingFile(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}
function mergeCandles(existing: PriceCandle[], incoming: PriceCandle[]): PriceCandle[] {
const byDate = new Map<string, PriceCandle>();
for (const candle of existing) {
byDate.set(candle.date, candle);
}
for (const candle of incoming) {
byDate.set(candle.date, candle);
}
return Array.from(byDate.values()).sort((left, right) => left.date.localeCompare(right.date));
}
function previousCalendarDate(date: string | undefined): string | undefined {
if (!date) {
return undefined;
}
const value = new Date(`${date}T00:00:00.000Z`);
if (!Number.isFinite(value.getTime())) {
return undefined;
}
value.setUTCDate(value.getUTCDate() - 7);
return value.toISOString().slice(0, 10);
}
function hasResearchSchema(data: RelatedStockData): boolean {
return Array.isArray(data.sources) && data.sources.some((source) => source.name.startsWith("Nasdaq") || source.name.startsWith("Sina"));
}
function isSameShanghaiDate(cachedAt: string, now: Date): boolean {
const cachedTime = new Date(cachedAt);
if (!Number.isFinite(cachedTime.getTime())) {
return false;
}
return shanghaiDate(cachedTime) === shanghaiDate(now);
}
function shanghaiDate(date: Date): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit"
}).format(date);
}
function aggregateCandles(candles: PriceCandle[], period: ChartPeriod): PriceCandle[] {
if (period === "D") {
return candles;
}
const grouped = new Map<string, PriceCandle>();
for (const candle of candles) {
const key = periodKey(candle.date, period);
const existing = grouped.get(key);
if (!existing) {
grouped.set(key, { ...candle });
continue;
}
existing.high = Math.max(existing.high, candle.high);
existing.low = Math.min(existing.low, candle.low);
existing.close = candle.close;
existing.volume += candle.volume;
existing.date = candle.date;
}
return Array.from(grouped.values());
}
function periodKey(date: string, period: ChartPeriod): string {
if (period === "M") {
return date.slice(0, 7);
}
if (period === "Y") {
return date.slice(0, 4);
}
return isoWeekKey(date);
}
function isoWeekKey(date: string): string {
const value = new Date(`${date}T00:00:00.000Z`);
const day = value.getUTCDay() || 7;
value.setUTCDate(value.getUTCDate() + 4 - day);
const yearStart = new Date(Date.UTC(value.getUTCFullYear(), 0, 1));
const week = Math.ceil(((value.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
return `${value.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
}

52
web/src/agent/symbols.ts Normal file
View File

@@ -0,0 +1,52 @@
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)) {
const exchange = inferChinaExchange(base);
return {
symbol: base,
market: "CN",
display: `${base}.${exchange}`
};
}
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("5") || symbol.startsWith("6") || symbol.startsWith("9") ? "SS" : "SZ";
}
export function marketLabel(market: Market): string {
return market === "CN" ? "A股" : market === "HK" ? "港股" : "美股";
}

View File

@@ -0,0 +1,96 @@
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, tags: 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,
tags: normalizeTags(tags),
addedAt: new Date().toISOString()
};
await this.write([...stocks, stock]);
return stock;
}
async updateTags(symbolInput: string, tags: string[]): Promise<WatchStock> {
const normalized = normalizeSymbol(symbolInput);
const stocks = await this.read();
const stock = stocks.find((item) => item.display === normalized.display);
if (!stock) {
throw new Error("Stock is not in watchlist");
}
const updated = { ...stock, tags: normalizeTags(tags) };
await this.write(stocks.map((item) => (item.display === normalized.display ? updated : item)));
return updated;
}
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[];
if (!Array.isArray(parsed)) {
return [];
}
const byDisplay = new Map<string, WatchStock>();
for (const stock of parsed) {
const normalized = normalizeSymbol(stock.display ?? stock.symbol);
byDisplay.set(normalized.display, {
...stock,
...normalized,
id: normalized.display,
tags: normalizeTags(stock.tags ?? [])
});
}
return Array.from(byDisplay.values());
} 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";
}
function normalizeTags(tags: string[]): string[] {
return Array.from(
new Set(
tags
.map((tag) => tag.trim())
.filter(Boolean)
.map((tag) => tag.slice(0, 24))
)
).slice(0, 8);
}

686
web/src/client/LivePage.tsx Normal file
View File

@@ -0,0 +1,686 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
ColorType,
CrosshairMode,
LineSeries,
createChart,
type IChartApi,
type LineData,
type Time,
} from "lightweight-charts";
import { ArrowDownRight, ArrowUpRight, RefreshCw, Wallet } from "lucide-react";
import {
calendarDaysSince,
currencyForMarket,
formatMoney,
formatNumber,
formatPct,
type EquityPoint,
type LiveStrategyState,
type StrategyCurrency,
type StrategyMarket,
type TradeLogEntry,
} from "../shared/strategyTypes";
// A strategy whose state was last updated more than this many calendar days
// ago is flagged (covers a normal weekend plus a holiday).
const LIVE_STALE_DAYS = 4;
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}`);
}
return (await response.json()) as T;
}
type SortKey = "currentValue" | "unrealizedPnlPct" | "name" | "lastUpdated";
export function LivePage() {
const [market, setMarket] = useState<StrategyMarket>("us");
const [strategies, setStrategies] = useState<LiveStrategyState[]>([]);
const [selectedName, setSelectedName] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("unrealizedPnlPct");
const [filterText, setFilterText] = useState("");
const refresh = (mk: StrategyMarket = market) => {
setLoading(true);
setError("");
api<LiveStrategyState[]>(`/api/strategies/live?market=${mk}`)
.then((res) => {
setStrategies(res);
if (res.length > 0) {
setSelectedName((prev) =>
prev && res.some((s) => s.name === prev) ? prev : res[0].name,
);
} else {
setSelectedName(null);
}
})
.catch((err) => setError(err instanceof Error ? err.message : "加载失败"))
.finally(() => setLoading(false));
};
useEffect(() => {
refresh(market);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [market]);
const sorted = useMemo(() => {
const list = [...strategies];
const ft = filterText.trim().toLowerCase();
const filtered = ft
? list.filter(
(s) =>
s.name.toLowerCase().includes(ft) ||
s.label.toLowerCase().includes(ft),
)
: list;
filtered.sort((a, b) => {
switch (sortKey) {
case "currentValue":
return (b.currentValue ?? 0) - (a.currentValue ?? 0);
case "unrealizedPnlPct":
return (b.unrealizedPnlPct ?? 0) - (a.unrealizedPnlPct ?? 0);
case "name":
return a.label.localeCompare(b.label);
case "lastUpdated":
return (b.lastUpdated ?? "").localeCompare(a.lastUpdated ?? "");
}
});
return filtered;
}, [strategies, sortKey, filterText]);
const focused = useMemo(
() => sorted.find((s) => s.name === selectedName) ?? sorted[0],
[sorted, selectedName],
);
return (
<div className="livePage">
{error && <div className="alert">{error}</div>}
<div className="liveTopbar">
<div className="segmented small">
<button className={market === "us" ? "active" : ""} onClick={() => setMarket("us")}>美股</button>
<button className={market === "cn" ? "active" : ""} onClick={() => setMarket("cn")}>A股</button>
</div>
<input
className="ghostInput"
placeholder="筛选策略..."
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
/>
<label className="ghostSelectLabel">
<span>排序</span>
<select
className="ghostSelect"
value={sortKey}
onChange={(e) => setSortKey(e.target.value as SortKey)}
>
<option value="unrealizedPnlPct">盈亏%</option>
<option value="currentValue">总资产</option>
<option value="name">名称</option>
<option value="lastUpdated">更新时间</option>
</select>
</label>
<button
className={`ghostButton ${loading ? "loading" : ""}`}
onClick={() => refresh()}
disabled={loading}
title="重新读取 live 状态(不会拉取新行情)"
type="button"
>
<RefreshCw size={14} />
{loading ? "加载中" : "刷新"}
</button>
</div>
<div className="liveLayout">
<aside className="panel liveSidebar">
<div className="panelHeader">
<h2>策略列表</h2>
<span>{sorted.length} / {strategies.length}</span>
</div>
{loading && sorted.length === 0 ? (
<p className="hint">加载中...</p>
) : sorted.length === 0 ? (
<p className="hint">暂无在线策略。运行 quant 的 trader.py auto 后再刷新。</p>
) : (
<div className="liveList">
{sorted.map((s) => (
<button
key={s.name}
className={`liveListItem ${focused?.name === s.name ? "selected" : ""}`}
onClick={() => setSelectedName(s.name)}
title={`${s.label} (${s.name})`}
type="button"
>
<div className="liveListTitle">
<strong>{s.label}</strong>
<span className="liveListName">{s.name}</span>
</div>
<div className="liveListBody">
<span className="liveListValue">{money(s.currentValue, currencyOf(s))}</span>
<span
className={`liveListPnl ${s.unrealizedPnl >= 0 ? "positive" : "negative"}`}
>
{s.unrealizedPnl >= 0 ? <ArrowUpRight size={11} /> : <ArrowDownRight size={11} />}
{pct(s.unrealizedPnlPct)}
</span>
</div>
<div className="liveListMeta">
<span>持仓 {Object.keys(s.holdings).length} · 起 {s.startDate ?? "—"}</span>
<span>
{isStale(s) && <span className="staleBadge">滞后</span>}
{s.lastUpdated ?? "-"}
</span>
</div>
</button>
))}
</div>
)}
</aside>
<section className="liveDetail">
{focused ? (
<LiveStrategyDetail key={focused.name} strategy={focused} />
) : (
<div className="panel emptyState">
<Wallet size={40} />
<p>未选择策略</p>
</div>
)}
</section>
</div>
</div>
);
}
function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) {
const m = strategy;
const currency = currencyOf(m);
const staleDays = calendarDaysSince(m.lastUpdated);
const bench = benchmarkComparison(m);
return (
<>
<div className="panel liveSummary">
<div className="liveSummaryHead">
<div>
<p className="eyebrow">{m.name}</p>
<h2>{m.label}</h2>
<p className="hint">
{m.market.toUpperCase()} · {currency} · 初始 {money(m.initialCapital, currency)} · 起始 {m.startDate ?? "—"}
{" "}· 更新于 {m.lastUpdated ?? "-"} · 价格截至 {m.priceAsOf ?? "—"}
{isStale(m) && <span className="staleBadge">已 {staleDays} 天未更新</span>}
</p>
</div>
<div className={`liveTotalPnl ${m.unrealizedPnl >= 0 ? "positive" : "negative"}`}>
<strong>{money(m.currentValue, currency)}</strong>
<span>
{m.unrealizedPnl >= 0 ? "+" : ""}
{money(m.unrealizedPnl, currency)} ({pct(m.unrealizedPnlPct)})
</span>
</div>
</div>
<div className="liveSummaryGrid">
<SummaryCell label="现金" value={money(m.cash, currency)} />
<SummaryCell label="持仓市值" value={money(m.marketValue, currency)} />
<SummaryCell label="总资产" value={money(m.currentValue, currency)} />
<SummaryCell label="持仓数" value={`${Object.keys(m.holdings).length}`} />
<SummaryCell label="累计交易" value={`${m.tradeLog.length}`} />
{m.metrics && <SummaryCell label="Sharpe" value={formatNumber(m.metrics.sharpeRatio)} />}
{m.metrics && <SummaryCell label="最大回撤" value={pct(m.metrics.maxDrawdown)} negative />}
{m.metrics && <SummaryCell label="日胜率" value={pct(m.metrics.winRate)} />}
{bench ? (
<>
<SummaryCell label={`同期基准 ${bench.name}`} value={pct(bench.benchmarkReturn)} />
<SummaryCell
label="超额收益 (同期)"
value={pct(bench.excessReturn)}
positive={bench.excessReturn > 0}
negative={bench.excessReturn < 0}
/>
</>
) : (
<SummaryCell label="基准数据暂缺" value="—" />
)}
</div>
</div>
<LiveEquityChart
equityCurve={m.equityCurve}
initialCapital={m.initialCapital}
currency={currency}
benchmark={bench ? m.benchmark : undefined}
/>
<HoldingsPanel
holdings={m.holdingsDetail}
cash={m.cash}
currentValue={m.currentValue}
currency={currency}
/>
<TradeLogPanel log={m.tradeLog} currency={currency} />
</>
);
}
function currencyOf(s: LiveStrategyState): StrategyCurrency {
return s.currency ?? currencyForMarket(s.market);
}
function isStale(s: LiveStrategyState): boolean {
const days = calendarDaysSince(s.lastUpdated);
return days !== null && days > LIVE_STALE_DAYS;
}
// Strategy vs. benchmark over the benchmark's window. The benchmark is rebased
// to initialCapital, so both returns share the same starting capital.
function benchmarkComparison(s: LiveStrategyState) {
const curve = s.benchmark?.equityCurve ?? [];
const first = curve[0]?.value;
const last = curve.at(-1);
if (!last || !first || !s.initialCapital || s.equityCurve.length === 0) return null;
const strategyEnd = s.equityCurve.find((p) => p.date === last.date) ?? s.equityCurve.at(-1)!;
const benchmarkReturn = last.value / first - 1;
const strategyReturn = strategyEnd.value / s.initialCapital - 1;
if (!Number.isFinite(benchmarkReturn) || !Number.isFinite(strategyReturn)) return null;
return {
name: s.benchmark?.name ?? "基准",
benchmarkReturn,
excessReturn: strategyReturn - benchmarkReturn,
};
}
function SummaryCell({
label,
value,
positive,
negative,
}: {
label: string;
value: string;
positive?: boolean;
negative?: boolean;
}) {
const cls = positive ? "positive" : negative ? "negative" : "";
return (
<div className={`summaryCell ${cls}`}>
<span className="summaryValue">{value}</span>
<span className="summaryLabel">{label}</span>
</div>
);
}
function LiveEquityChart({
equityCurve,
initialCapital,
currency,
benchmark,
}: {
equityCurve: EquityPoint[];
initialCapital: number;
currency: StrategyCurrency;
benchmark?: { name: string; equityCurve: EquityPoint[] };
}) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
useEffect(() => {
if (!containerRef.current) return;
const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth,
height: 280,
layout: {
background: { type: ColorType.Solid, color: "#0d1219" },
textColor: "#b0b0b0",
attributionLogo: false,
},
grid: {
vertLines: { color: "#1a2030" },
horzLines: { color: "#1a2030" },
},
crosshair: { mode: CrosshairMode.Normal },
timeScale: { borderColor: "#1d2530", timeVisible: false },
rightPriceScale: { borderColor: "#1d2530" },
localization: { priceFormatter: (price: number) => formatMoney(price, currency) },
});
chartRef.current = chart;
if (benchmark && benchmark.equityCurve.length > 0) {
const benchSeries = chart.addSeries(LineSeries, {
color: "#7c8694",
lineWidth: 1,
lineStyle: 2,
title: benchmark.name,
crosshairMarkerVisible: false,
priceLineVisible: false,
});
benchSeries.setData(
benchmark.equityCurve.map((p) => ({ time: p.date as Time, value: p.value })),
);
}
const eqSeries = chart.addSeries(LineSeries, {
color: "#4db6ff",
lineWidth: 2,
priceLineVisible: false,
});
const data: LineData<Time>[] = equityCurve.map((p) => ({
time: p.date as Time,
value: p.value,
}));
eqSeries.setData(data);
if (initialCapital && equityCurve.length > 0) {
eqSeries.createPriceLine({
price: initialCapital,
color: "#5e6a78",
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: "初始",
});
}
chart.timeScale().fitContent();
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w) chart.applyOptions({ width: w });
});
ro.observe(containerRef.current);
return () => {
ro.disconnect();
chart.remove();
chartRef.current = null;
};
}, [equityCurve, initialCapital, currency, benchmark]);
return (
<div className="panel chartPanel">
<div className="panelHeader">
<div>
<p className="eyebrow">Live Equity</p>
<h2>资产曲线</h2>
<p className="hint">
{equityCurve.length} 天数据
{equityCurve.length > 0
? ` · ${equityCurve[0].date} → ${equityCurve[equityCurve.length - 1].date}`
: ""}
{benchmark ? ` · 虚线为 ${benchmark.name}(同期,按初始资金归一)` : " · 基准数据暂缺"}
</p>
</div>
</div>
<div ref={containerRef} className="liveEquityChart" />
</div>
);
}
const DEFAULT_PAGE_SIZE = 30;
const PAGE_SIZE_OPTIONS = [30, 50, 100];
type PaginationState = { page: number; pageSize: number };
function usePagination(total: number, deps: unknown[] = []): [
PaginationState,
React.Dispatch<React.SetStateAction<PaginationState>>,
{ totalPages: number; sliceStart: number; sliceEnd: number },
] {
const [state, setState] = useState<PaginationState>({
page: 1,
pageSize: DEFAULT_PAGE_SIZE,
});
// Reset to page 1 whenever the underlying data identity changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => setState((s) => ({ ...s, page: 1 })), deps);
const totalPages = Math.max(1, Math.ceil(total / state.pageSize));
const safePage = Math.min(state.page, totalPages);
const sliceStart = (safePage - 1) * state.pageSize;
const sliceEnd = Math.min(total, sliceStart + state.pageSize);
return [
{ ...state, page: safePage },
setState,
{ totalPages, sliceStart, sliceEnd },
];
}
function PaginationBar({
page,
pageSize,
totalPages,
total,
sliceStart,
sliceEnd,
onPageChange,
onPageSizeChange,
}: {
page: number;
pageSize: number;
totalPages: number;
total: number;
sliceStart: number;
sliceEnd: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}) {
if (total === 0) return null;
return (
<div className="paginationBar">
<span className="paginationRange">
{sliceStart + 1}–{sliceEnd} / {total}
</span>
<div className="paginationControls">
<button
className="paginationButton"
onClick={() => onPageChange(1)}
disabled={page <= 1}
>
«
</button>
<button
className="paginationButton"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
>
‹
</button>
<span className="paginationPage">
{page} / {totalPages}
</span>
<button
className="paginationButton"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
>
›
</button>
<button
className="paginationButton"
onClick={() => onPageChange(totalPages)}
disabled={page >= totalPages}
>
»
</button>
<select
className="ghostSelect ghostSelectCompact"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
>
{PAGE_SIZE_OPTIONS.map((sz) => (
<option key={sz} value={sz}>
{sz} / 页
</option>
))}
</select>
</div>
</div>
);
}
function HoldingsPanel({
holdings,
cash,
currentValue,
currency,
}: {
holdings: LiveStrategyState["holdingsDetail"];
cash: number;
currentValue: number;
currency: StrategyCurrency;
}) {
const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] =
usePagination(holdings.length, [holdings]);
const visible = holdings.slice(sliceStart, sliceEnd);
const cashWeight = currentValue ? cash / currentValue : 0;
return (
<div className="panel holdingsPanel">
<div className="panelHeader">
<h2>当前持仓</h2>
<span>{holdings.length} 只</span>
</div>
{holdings.length === 0 ? (
<p className="hint">当前空仓,全部为现金。</p>
) : (
<>
<table className="holdingsTable">
<thead>
<tr>
<th>标的</th>
<th>持股</th>
<th>最新价</th>
<th>市值</th>
<th>占组合</th>
</tr>
</thead>
<tbody>
{visible.map((h) => {
const weight = currentValue ? (h.marketValue ?? 0) / currentValue : 0;
return (
<tr key={h.ticker}>
<td>
<strong>{h.ticker}</strong>
</td>
<td>{h.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
<td>
{money(h.lastPrice, currency, 2)}
{h.priceStale && (
<span className="staleBadge" title="最新行情缺失,使用最近一次记录价格">价格未更新</span>
)}
</td>
<td>{money(h.marketValue, currency)}</td>
<td>{pct(weight)}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="holdingsCashRow">
<td>
<strong>现金</strong>
</td>
<td>-</td>
<td>-</td>
<td>{money(cash, currency)}</td>
<td>{pct(cashWeight)}</td>
</tr>
</tfoot>
</table>
<PaginationBar
page={page}
pageSize={pageSize}
totalPages={totalPages}
total={holdings.length}
sliceStart={sliceStart}
sliceEnd={sliceEnd}
onPageChange={(p) => setPage((s) => ({ ...s, page: p }))}
onPageSizeChange={(sz) => setPage(() => ({ page: 1, pageSize: sz }))}
/>
</>
)}
</div>
);
}
function TradeLogPanel({ log, currency }: { log: TradeLogEntry[]; currency: StrategyCurrency }) {
// Render newest-first; the trader logs trades chronologically.
const reversed = useMemo(() => [...log].reverse(), [log]);
const [{ page, pageSize }, setPage, { totalPages, sliceStart, sliceEnd }] =
usePagination(reversed.length, [log]);
const visible = reversed.slice(sliceStart, sliceEnd);
return (
<div className="panel tradeLogPanel">
<div className="panelHeader">
<h2>交易历史</h2>
<span>{log.length} 笔</span>
</div>
{log.length === 0 ? (
<p className="hint">尚无交易记录。</p>
) : (
<>
<table className="tradeLogTable">
<thead>
<tr>
<th>日期</th>
<th>方向</th>
<th>标的</th>
<th>股数</th>
<th>成交价</th>
<th>金额</th>
<th>手续费</th>
</tr>
</thead>
<tbody>
{visible.map((t, i) => (
<tr
key={`${t.date}-${t.ticker}-${sliceStart + i}`}
className={t.action === "BUY" ? "buy" : "sell"}
>
<td>{t.date}</td>
<td>
<span className={`tradeAction ${t.action.toLowerCase()}`}>
{t.action === "BUY" ? "买入" : "卖出"}
</span>
</td>
<td>
<strong>{t.ticker}</strong>
</td>
<td>{t.shares.toLocaleString(undefined, { maximumFractionDigits: 2 })}</td>
<td>{money(t.price, currency, 2)}</td>
<td>{money(t.value, currency)}</td>
<td>{money(t.commission, currency, 2)}</td>
</tr>
))}
</tbody>
</table>
<PaginationBar
page={page}
pageSize={pageSize}
totalPages={totalPages}
total={log.length}
sliceStart={sliceStart}
sliceEnd={sliceEnd}
onPageChange={(p) => setPage((s) => ({ ...s, page: p }))}
onPageSizeChange={(sz) => setPage(() => ({ page: 1, pageSize: sz }))}
/>
</>
)}
</div>
);
}
const money = formatMoney;
const pct = formatPct;

View File

@@ -0,0 +1,984 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import {
ColorType,
CrosshairMode,
LineSeries,
createChart,
type IChartApi,
type ISeriesApi,
type LineData,
type Time,
} from "lightweight-charts";
import { ArrowDown, ArrowUp, BarChart3, Trophy, TrendingDown, TrendingUp } from "lucide-react";
import {
calendarDaysSince,
currencyForMarket,
formatMoney,
formatNumber,
formatPct,
type BacktestResult,
type PrecomputedIndex,
type PrecomputedScenarioInfo,
type StrategyInfo,
type StrategyMarket,
type StrategyMetrics,
type StrategyResult,
} from "../shared/strategyTypes";
const STRATEGY_COLORS = [
"#4db6ff", "#f3c969", "#c084fc", "#26a69a", "#ef5350",
"#ff9800", "#8bc34a", "#e91e63", "#00bcd4", "#cddc39",
];
const MAX_CHART_SELECTION = 10;
// Precomputed data older than this (calendar days) is flagged as stale.
const STALE_DATA_DAYS = 7;
type RankMetric =
| "annualizedReturn"
| "sharpeRatio"
| "sortinoRatio"
| "calmarRatio"
| "maxDrawdown"
| "winRate"
| "rebalanceWinRate"
| "totalReturn";
const RANK_METRICS: Array<{ key: RankMetric; label: string; better: "high" | "low" }> = [
{ key: "sharpeRatio", label: "Sharpe", better: "high" },
{ key: "annualizedReturn", label: "年化", better: "high" },
{ key: "totalReturn", label: "总收益", better: "high" },
{ key: "calmarRatio", label: "Calmar", better: "high" },
{ key: "sortinoRatio", label: "Sortino", better: "high" },
// maxDrawdown is negative: closer to zero (higher) is better.
{ key: "maxDrawdown", label: "MaxDD", better: "high" },
{ key: "winRate", label: "日胜率", better: "high" },
{ key: "rebalanceWinRate", label: "再平衡胜率", better: "high" },
];
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}`);
}
return (await response.json()) as T;
}
export function StrategyPage() {
const [strategies, setStrategies] = useState<StrategyInfo[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [market, setMarket] = useState<StrategyMarket>("us");
const [scenarioList, setScenarioList] = useState<PrecomputedScenarioInfo[]>([]);
const [scenarioId, setScenarioId] = useState<string | null>(null);
const [precomputed, setPrecomputed] = useState<BacktestResult | undefined>();
const [precomputedError, setPrecomputedError] = useState("");
const [error, setError] = useState("");
const [focusStrategy, setFocusStrategy] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState<string>("all");
const [rankMetric, setRankMetric] = useState<RankMetric>("sharpeRatio");
const [loading, setLoading] = useState(true);
useEffect(() => {
api<StrategyInfo[]>("/api/strategies")
.then(setStrategies)
.catch((err) => setError(err.message));
}, []);
useEffect(() => {
api<PrecomputedIndex>("/api/strategies/precomputed/index")
.then((idx) => {
setScenarioList(idx.scenarios);
if (idx.scenarios.length > 0 && !scenarioId) {
setScenarioId(idx.scenarios[0].id);
}
})
.catch((err) => setPrecomputedError(err instanceof Error ? err.message : "无法加载场景列表"));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Prefer the scenario the server says it served over the requested one.
const servedScenarioId = precomputed?.scenarioId ?? scenarioId;
const currentScenario = useMemo(
() => scenarioList.find((s) => s.id === servedScenarioId) ?? null,
[scenarioList, servedScenarioId],
);
useEffect(() => {
if (!scenarioId) return;
setLoading(true);
setPrecomputedError("");
setPrecomputed(undefined);
api<BacktestResult>(`/api/strategies/precomputed?market=${market}&scenario=${encodeURIComponent(scenarioId)}`)
.then((res) => {
setPrecomputed(res);
const ranked = rankBy(res.strategies, rankMetric).filter((s) => !s.ruined);
const top = new Set(ranked.slice(0, 5).map((s) => s.name));
setSelected(top);
if (ranked.length > 0) setFocusStrategy(ranked[0].name);
})
.catch((err) => {
setPrecomputedError(err instanceof Error ? err.message : "无法加载预计算结果");
})
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [market, scenarioId]);
const categoryByName = useMemo(() => {
const map = new Map<string, string>();
for (const s of strategies) map.set(s.name, s.category);
return map;
}, [strategies]);
const categories = useMemo(() => {
if (!precomputed) return ["all"];
const seen = new Set<string>();
for (const s of precomputed.strategies) {
seen.add(categoryByName.get(s.name) ?? "other");
}
return ["all", ...Array.from(seen).sort()];
}, [precomputed, categoryByName]);
const filteredStrategies = useMemo(() => {
if (!precomputed) return [];
if (categoryFilter === "all") return precomputed.strategies;
return precomputed.strategies.filter(
(s) => (categoryByName.get(s.name) ?? "other") === categoryFilter,
);
}, [precomputed, categoryFilter, categoryByName]);
const rankedStrategies = useMemo(
() => rankBy(filteredStrategies, rankMetric),
[filteredStrategies, rankMetric],
);
const rankIndex = useMemo(() => {
const map = new Map<string, number>();
rankedStrategies.forEach((s, i) => map.set(s.name, i + 1));
return map;
}, [rankedStrategies]);
const chartResult: BacktestResult | undefined = useMemo(() => {
if (!precomputed) return undefined;
return {
...precomputed,
strategies: rankedStrategies.filter((s) => selected.has(s.name)),
};
}, [precomputed, rankedStrategies, selected]);
function toggleStrategy(name: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
if (next.size >= MAX_CHART_SELECTION) return prev;
next.add(name);
}
return next;
});
setFocusStrategy(name);
}
function selectTopN(metric: RankMetric, n: number) {
if (!precomputed) return;
const ranked = rankBy(filteredStrategies, metric).filter((s) => !s.ruined);
const top = new Set(ranked.slice(0, n).map((s) => s.name));
setSelected(top);
if (ranked.length > 0) setFocusStrategy(ranked[0].name);
setRankMetric(metric);
}
function clearSelection() {
setSelected(new Set());
}
const meta = precomputed?.meta;
const computedAt = precomputed?.computedAt ? new Date(precomputed.computedAt) : null;
const dataAgeDays = calendarDaysSince(meta?.endDate);
const dataStale = dataAgeDays !== null && dataAgeDays > STALE_DATA_DAYS;
return (
<div className="strategyPage">
{error && <div className="alert">{error}</div>}
{precomputedError && <div className="alert">{precomputedError}</div>}
{dataStale && (
<div className="alert warning">
预计算数据截至 {meta?.endDate},已滞后 {dataAgeDays} 天。在 quant 项目重新运行 <code>precompute_backtest.py</code> 以更新。
</div>
)}
<div className="strategyLayout">
{/* Left: Strategy selector */}
<aside className="panel strategySelector">
<div className="panelHeader">
<h2>策略选择</h2>
<span>{selected.size} / {filteredStrategies.length}</span>
</div>
<div className="precomputedHeader">
<div className="precomputedHeaderRow">
<label>市场</label>
<div className="segmented small">
<button className={market === "us" ? "active" : ""} onClick={() => setMarket("us")}>美股</button>
<button className={market === "cn" ? "active" : ""} onClick={() => setMarket("cn")}>A股</button>
</div>
</div>
{scenarioList.length > 0 && (
<div className="scenarioPicker">
<span className="scenarioPickerLabel">投入方式</span>
<div className="scenarioOptions">
{scenarioList.map((sc) => (
<button
key={sc.id}
className={`scenarioOption ${scenarioId === sc.id ? "active" : ""}`}
onClick={() => setScenarioId(sc.id)}
type="button"
title={scenarioDescription(sc, market)}
>
<strong>{scenarioLabel(sc, market)}</strong>
<span>{summarizeScenario(sc, market)}</span>
</button>
))}
</div>
</div>
)}
{meta && (
<p className="precomputedHint">
{meta.years} 年 · 离线预计算 · 数据截至 {meta.endDate ?? "—"}
{computedAt ? ` · 计算于 ${computedAt.toLocaleDateString()}` : ""}
{dataStale && <span className="staleBadge">数据滞后 {dataAgeDays} 天</span>}
</p>
)}
</div>
<div className="quickPicks">
<span className="quickPicksLabel">快速选取</span>
<div className="quickPicksRow">
<button className="quickPickButton" onClick={() => selectTopN("sharpeRatio", 5)}>
<Trophy size={12} /> Top 5 Sharpe
</button>
<button className="quickPickButton" onClick={() => selectTopN("annualizedReturn", 5)}>
<Trophy size={12} /> Top 5 年化
</button>
<button className="quickPickButton ghost" onClick={clearSelection}>清空</button>
</div>
</div>
{categories.length > 1 && (
<div className="tagFilter">
{categories.map((cat) => (
<button
className={categoryFilter === cat ? "tagChip active" : "tagChip"}
key={cat}
onClick={() => setCategoryFilter(cat)}
>
{categoryLabel(cat)}
</button>
))}
</div>
)}
<div className="strategyList">
{loading && <p className="strategyEmpty">加载预计算结果中...</p>}
{!loading && rankedStrategies.length === 0 && (
<p className="strategyEmpty">当前筛选下暂无策略</p>
)}
{rankedStrategies.map((s, i) => {
const isSelected = selected.has(s.name);
const colorIdx = chartResult?.strategies.findIndex((x) => x.name === s.name) ?? -1;
const color = colorIdx >= 0 ? STRATEGY_COLORS[colorIdx % STRATEGY_COLORS.length] : undefined;
return (
<label
className={`strategyItem ${isSelected ? "selected" : ""}`}
key={s.name}
title={s.name}
>
<span className="strategyRank" data-top={i < 3 && !s.ruined ? "1" : "0"}>#{i + 1}</span>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleStrategy(s.name)}
/>
<span
className="strategyDot"
style={{ background: color ?? "transparent", borderColor: color ?? "#2a3344" }}
/>
<span className="strategyLabel">
{displayStrategyName(s)}
{s.ruined && <RuinedBadge />}
</span>
<span className={`strategyMetric ${signClass(s.metrics.annualizedReturn)}`}>
{pct(s.metrics.annualizedReturn)}
</span>
<span className="strategyMetricMuted">
{num(s.metrics.sharpeRatio)}
</span>
</label>
);
})}
</div>
</aside>
{/* Right: Results */}
<section className="strategyResults">
{chartResult && precomputed ? (
<>
<EquityCurveChart
result={chartResult}
focusStrategy={focusStrategy}
onFocus={setFocusStrategy}
scenario={currentScenario}
/>
<RankingLeaderboard
strategies={rankedStrategies}
selected={selected}
onToggle={toggleStrategy}
onFocus={setFocusStrategy}
rankMetric={rankMetric}
onChangeMetric={setRankMetric}
categoryByName={categoryByName}
rankIndex={rankIndex}
timeWeighted={hasInjections(chartResult)}
/>
<div className="insightGrid">
<MetricsPanel result={chartResult} focusStrategy={focusStrategy} onFocus={setFocusStrategy} />
<DrawdownInfo result={chartResult} focusStrategy={focusStrategy} />
</div>
<ReturnHeatmap result={chartResult} focusStrategy={focusStrategy} />
</>
) : (
<div className="panel emptyState">
<TrendingUp size={48} />
<p>{loading ? "正在加载预计算回测结果..." : "暂无预计算回测结果"}</p>
<p className="hint">在 quant 项目运行 <code>precompute_backtest.py</code> 后即可查看</p>
</div>
)}
</section>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Ranking Leaderboard
// ---------------------------------------------------------------------------
function RankingLeaderboard({
strategies,
selected,
onToggle,
onFocus,
rankMetric,
onChangeMetric,
categoryByName,
rankIndex,
timeWeighted,
}: {
strategies: StrategyResult[];
selected: Set<string>;
onToggle: (name: string) => void;
onFocus: (name: string) => void;
rankMetric: RankMetric;
onChangeMetric: (metric: RankMetric) => void;
categoryByName: Map<string, string>;
rankIndex: Map<string, number>;
timeWeighted: boolean;
}) {
return (
<div className="panel rankingPanel">
<div className="panelHeader">
<div>
<p className="eyebrow">Leaderboard</p>
<h2>策略排名</h2>
<p>
点击行加入/移除对比;列头切换排序指标
{timeWeighted ? ";收益/风险指标为时间加权 (TWR),不含注资影响" : ""}
</p>
</div>
<span>{strategies.length} 个策略</span>
</div>
<div className="rankingWrap">
<table className="rankingTable">
<thead>
<tr>
<th className="rankCol">#</th>
<th>策略</th>
<th>类别</th>
{RANK_METRICS.map((m) => {
const active = rankMetric === m.key;
return (
<th
key={m.key}
className={`sortable ${active ? "active" : ""}`}
onClick={() => onChangeMetric(m.key)}
title={`按 ${m.label} 排序`}
>
{m.label}
{active && (m.better === "high" ? <ArrowDown size={11} /> : <ArrowUp size={11} />)}
</th>
);
})}
</tr>
</thead>
<tbody>
{strategies.map((s) => {
const isSelected = selected.has(s.name);
const rank = rankIndex.get(s.name) ?? 0;
return (
<tr
key={s.name}
className={`${isSelected ? "selected" : ""}`}
onClick={() => {
onToggle(s.name);
onFocus(s.name);
}}
>
<td className="rankCol">
<span className={`rankBadge ${rank <= 3 && !s.ruined ? "top" : ""}`}>{rank}</span>
</td>
<td>
<strong>{displayStrategyName(s)}</strong>
{s.ruined && <RuinedBadge />}
<span className="rankSub">{s.name}</span>
</td>
<td>
<span className="categoryChip">
{categoryLabel(categoryByName.get(s.name) ?? "other")}
</span>
</td>
<td>{num(s.metrics.sharpeRatio)}</td>
<td className={signClass(s.metrics.annualizedReturn)}>
{pct(s.metrics.annualizedReturn)}
</td>
<td className={signClass(s.metrics.totalReturn)}>
{pct(s.metrics.totalReturn)}
</td>
<td>{num(s.metrics.calmarRatio)}</td>
<td>{num(s.metrics.sortinoRatio)}</td>
<td className="negative">{pct(s.metrics.maxDrawdown)}</td>
<td>{pct(s.metrics.winRate)}</td>
<td>{pct(s.metrics.rebalanceWinRate)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Equity Curve Chart
// ---------------------------------------------------------------------------
function EquityCurveChart({
result,
focusStrategy,
onFocus,
scenario,
}: {
result: BacktestResult;
focusStrategy: string | null;
onFocus: (name: string) => void;
scenario: PrecomputedScenarioInfo | null;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesMapRef = useRef<Map<string, ISeriesApi<"Line">>>(new Map());
const currency = currencyForMarket(result.meta.market);
useEffect(() => {
if (!containerRef.current) return;
const chart = createChart(containerRef.current, {
width: containerRef.current.clientWidth,
height: 480,
layout: {
background: { type: ColorType.Solid, color: "#0d1219" },
textColor: "#b0b0b0",
attributionLogo: false,
},
grid: {
vertLines: { color: "#1a2030" },
horzLines: { color: "#1a2030" },
},
crosshair: { mode: CrosshairMode.Normal },
timeScale: { borderColor: "#1d2530", timeVisible: false },
rightPriceScale: { borderColor: "#1d2530" },
});
chartRef.current = chart;
const observer = new ResizeObserver((entries) => {
const width = entries[0]?.contentRect.width;
if (width) chart.applyOptions({ width });
});
observer.observe(containerRef.current);
return () => {
observer.disconnect();
chart.remove();
chartRef.current = null;
seriesMapRef.current.clear();
};
}, []);
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
chart.applyOptions({
localization: { priceFormatter: (price: number) => formatMoney(price, currency) },
});
for (const series of seriesMapRef.current.values()) {
chart.removeSeries(series);
}
seriesMapRef.current.clear();
result.strategies.forEach((strat, i) => {
const color = STRATEGY_COLORS[i % STRATEGY_COLORS.length];
const lineWidth = focusStrategy === strat.name ? 3 : 1.6;
const series = chart.addSeries(LineSeries, {
color,
lineWidth: lineWidth as 1 | 2 | 3 | 4,
crosshairMarkerVisible: true,
priceLineVisible: false,
});
const data: LineData<Time>[] = strat.equityCurve.map((p) => ({
time: p.date as Time,
value: p.value,
}));
series.setData(data);
seriesMapRef.current.set(strat.name, series);
});
if (result.benchmark.equityCurve.length > 0) {
const benchSeries = chart.addSeries(LineSeries, {
color: "#7c8694",
lineWidth: 1,
lineStyle: 2,
title: result.benchmark.name,
crosshairMarkerVisible: false,
priceLineVisible: false,
});
benchSeries.setData(
result.benchmark.equityCurve.map((p) => ({ time: p.date as Time, value: p.value })),
);
seriesMapRef.current.set("__benchmark__", benchSeries);
}
chart.timeScale().fitContent();
}, [result, focusStrategy, currency]);
return (
<div className="panel chartPanel">
<div className="equityHeader">
<div className="equityTitle">
<p className="eyebrow">Equity Curves</p>
<h2>资产曲线对比</h2>
</div>
<div className="equityMeta">
<span className="equityMetaItem">{result.meta.market.toUpperCase()}</span>
<span className="equityMetaSep">·</span>
<span className="equityMetaItem">{result.meta.years} 年</span>
<span className="equityMetaSep">·</span>
<span className="equityMetaItem">{formatMoney(result.meta.initialCapital, currency)} 初始</span>
{scenario?.injectionSchedule && (
<>
<span className="equityMetaSep">·</span>
<span className="equityMetaItem">{summarizeScenario(scenario, result.meta.market)}</span>
</>
)}
</div>
</div>
<div className="legendRow">
{result.strategies.length === 0 && (
<span className="legendEmpty">从左侧选取策略加入对比</span>
)}
{result.strategies.map((strat, i) => (
<button
className={`legendItem ${focusStrategy === strat.name ? "focused" : ""}`}
key={strat.name}
onClick={() => onFocus(strat.name)}
type="button"
>
<span className="legendDot" style={{ background: STRATEGY_COLORS[i % STRATEGY_COLORS.length] }} />
{displayStrategyName(strat)}
</button>
))}
{result.benchmark.equityCurve.length ? (
<span className="legendItem benchmarkLegend">
<span className="legendDot" />
{result.benchmark.name}
</span>
) : null}
</div>
<div ref={containerRef} className="equityChartContainer" />
</div>
);
}
// ---------------------------------------------------------------------------
// Metrics Panel
// ---------------------------------------------------------------------------
function MetricsPanel({
result,
focusStrategy,
onFocus,
}: {
result: BacktestResult;
focusStrategy: string | null;
onFocus: (name: string) => void;
}) {
const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0];
const currency = currencyForMarket(result.meta.market);
// With cash injections the metrics are time-weighted; the money view comes
// from capitalFlows (never recomputed from the money curve here).
const timeWeighted = hasInjections(result);
const twr = timeWeighted ? " (TWR)" : "";
const showFlows = timeWeighted && result.strategies.some((s) => s.capitalFlows);
const flows = focused?.capitalFlows;
return (
<div className="panel metricsPanel">
<div className="panelHeader">
<h2>核心指标</h2>
{focused && (
<span>
{displayStrategyName(focused)}
{focused.ruined && <RuinedBadge />}
</span>
)}
</div>
{focused ? (
<>
{timeWeighted && flows && (
<>
<p className="hint">资金视角(含注资)</p>
<div className="metricsGrid">
<MetricCell label="投入本金" value={formatMoney(flows.invested, currency)} />
<MetricCell label="期末市值" value={formatMoney(flows.finalValue, currency)} />
<MetricCell
label="盈亏"
value={formatMoney(flows.profit, currency)}
positive={flows.profit > 0}
negative={flows.profit < 0}
/>
<MetricCell
label="本金收益率"
value={pct(flows.returnOnInvested)}
positive={isPositive(flows.returnOnInvested)}
negative={isNegative(flows.returnOnInvested)}
/>
</div>
</>
)}
{timeWeighted && <p className="hint">收益/风险指标为时间加权 (TWR),剔除注资影响</p>}
<div className="metricsGrid">
<MetricCell label={`总收益${twr}`} value={pct(focused.metrics.totalReturn)} positive={isPositive(focused.metrics.totalReturn)} />
<MetricCell label={`年化收益${twr}`} value={pct(focused.metrics.annualizedReturn)} positive={isPositive(focused.metrics.annualizedReturn)} />
<MetricCell label="年化波动" value={pct(focused.metrics.annualizedVolatility)} />
<MetricCell label="Sharpe" value={num(focused.metrics.sharpeRatio)} positive={isAbove(focused.metrics.sharpeRatio, 1)} />
<MetricCell label="Sortino" value={num(focused.metrics.sortinoRatio)} positive={isAbove(focused.metrics.sortinoRatio, 1)} />
<MetricCell label="最大回撤" value={pct(focused.metrics.maxDrawdown)} negative />
<MetricCell label="Calmar" value={num(focused.metrics.calmarRatio)} positive={isAbove(focused.metrics.calmarRatio, 0.5)} />
<MetricCell label="日胜率" value={pct(focused.metrics.winRate)} positive={isAbove(focused.metrics.winRate, 0.5)} />
<MetricCell
label="再平衡胜率"
value={pct(focused.metrics.rebalanceWinRate)}
positive={isAbove(focused.metrics.rebalanceWinRate, 0.5)}
/>
</div>
<div className="focusSummary">
<span><BarChart3 size={15} /> 终值 {formatMoney(flows?.finalValue ?? focused.equityCurve.at(-1)?.value, currency)}</span>
<span><TrendingDown size={15} /> 最大回撤 {pct(focused.metrics.maxDrawdown)}</span>
<span><TrendingUp size={15} /> 年化{twr} {pct(focused.metrics.annualizedReturn)}</span>
<span>交易 {focused.trades.length.toLocaleString()} 天</span>
</div>
</>
) : (
<p className="hint">从排名表选取至少一个策略查看核心指标</p>
)}
{result.strategies.length > 1 && (
<div className="comparisonWrap">
<table className="comparisonTable">
<thead>
<tr>
<th>策略</th>
{showFlows && (
<>
<th>投入本金</th>
<th>期末市值</th>
<th>盈亏</th>
<th>本金收益率</th>
</>
)}
<th>年化{twr}</th>
<th>Sharpe</th>
<th>MaxDD</th>
<th>日胜率</th>
<th>再平衡胜率</th>
</tr>
</thead>
<tbody>
{result.strategies.map((s) => (
<tr key={s.name} className={s.name === focusStrategy ? "focused" : ""} onClick={() => onFocus(s.name)}>
<td>
<strong>{displayStrategyName(s)}</strong>
{s.ruined && <RuinedBadge />}
<span>{s.name}</span>
</td>
{showFlows && (
<>
<td>{formatMoney(s.capitalFlows?.invested, currency)}</td>
<td>{formatMoney(s.capitalFlows?.finalValue, currency)}</td>
<td className={signClass(s.capitalFlows?.profit)}>{formatMoney(s.capitalFlows?.profit, currency)}</td>
<td className={signClass(s.capitalFlows?.returnOnInvested)}>{pct(s.capitalFlows?.returnOnInvested)}</td>
</>
)}
<td className={signClass(s.metrics.annualizedReturn)}>{pct(s.metrics.annualizedReturn)}</td>
<td>{num(s.metrics.sharpeRatio)}</td>
<td className="negative">{pct(s.metrics.maxDrawdown)}</td>
<td>{pct(s.metrics.winRate)}</td>
<td>{pct(s.metrics.rebalanceWinRate)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function MetricCell({ label, value, positive, negative }: { label: string; value: string; positive?: boolean; negative?: boolean }) {
const cls = positive ? "positive" : negative ? "negative" : "";
return (
<div className={`metricCell ${cls}`}>
<span className="metricValue">{value}</span>
<span className="metricLabel">{label}</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Drawdown Info
// ---------------------------------------------------------------------------
function DrawdownInfo({ result, focusStrategy }: { result: BacktestResult; focusStrategy: string | null }) {
const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0];
if (!focused) {
return (
<div className="panel drawdownPanel">
<div className="panelHeader"><h2>重大回撤</h2></div>
<p className="hint">无聚焦策略</p>
</div>
);
}
const topDrawdowns = [...focused.drawdowns].sort((a, b) => a.depth - b.depth).slice(0, 5);
return (
<div className="panel drawdownPanel">
<div className="panelHeader">
<h2>重大回撤</h2>
<span>{displayStrategyName(focused)}</span>
</div>
{topDrawdowns.length === 0 ? (
<p className="hint">无显著回撤期间 (阈值 -5%)</p>
) : (
<table className="drawdownTable">
<thead>
<tr>
<th>开始</th>
<th>结束</th>
<th>深度</th>
<th>恢复</th>
</tr>
</thead>
<tbody>
{topDrawdowns.map((dd, i) => (
<tr key={i}>
<td>{dd.start}</td>
<td>{dd.end}</td>
<td className="negative">{pct(dd.depth)}</td>
<td>{dd.recoveryDate ?? "未恢复"}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Return Heatmap
// ---------------------------------------------------------------------------
function ReturnHeatmap({ result, focusStrategy }: { result: BacktestResult; focusStrategy: string | null }) {
const focused = result.strategies.find((s) => s.name === focusStrategy) ?? result.strategies[0];
if (!focused) return null;
const { monthlyReturns, yearlyReturns } = focused;
const years = Object.keys(yearlyReturns).sort();
const months = ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"];
const monthLabels = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return (
<div className="panel heatmapPanel">
<div className="panelHeader">
<h2>收益热力图{hasInjections(result) ? " (TWR)" : ""}</h2>
<span>{displayStrategyName(focused)}</span>
</div>
<div className="heatmapContainer">
<table className="heatmapTable">
<thead>
<tr>
<th>Year</th>
{monthLabels.map((m) => <th key={m}>{m}</th>)}
<th>YTD</th>
</tr>
</thead>
<tbody>
{years.map((year) => (
<tr key={year}>
<td className="yearLabel">{year}</td>
{months.map((month) => {
const key = `${year}-${month}`;
const val = finiteOrUndefined(monthlyReturns[key]);
return (
<td key={month} className="heatCell" style={{ background: heatColor(val) }}>
{val !== undefined ? `${(val * 100).toFixed(1)}` : ""}
</td>
);
})}
<td className={`yearReturn ${signClass(yearlyReturns[year])}`}>
{pct(yearlyReturns[year])}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function rankBy(strategies: StrategyResult[], metric: RankMetric): StrategyResult[] {
const better = RANK_METRICS.find((m) => m.key === metric)?.better ?? "high";
const dir = better === "high" ? -1 : 1;
const get = (s: StrategyResult) => {
const v = s.metrics[metric as keyof StrategyMetrics];
return typeof v === "number" && Number.isFinite(v) ? v : (better === "high" ? -Infinity : Infinity);
};
// Ruined strategies always rank last so they never take a "best" slot.
return [...strategies].sort(
(a, b) => Number(!!a.ruined) - Number(!!b.ruined) || dir * (get(a) - get(b)),
);
}
function hasInjections(result: BacktestResult): boolean {
return !!result.meta.injectionSchedule;
}
// Scenario capital in the given market's currency. Older files only carry the
// base (US) `capital`, which then applies to every market.
function scenarioCapital(sc: PrecomputedScenarioInfo, market: StrategyMarket): number {
return sc.capitalByMarket?.[market] ?? sc.capital;
}
function summarizeScenario(sc: PrecomputedScenarioInfo, market: StrategyMarket): string {
const currency = currencyForMarket(market);
const capital = scenarioCapital(sc, market);
const fmt = (v: number) => formatMoney(v, currency);
if (!sc.injectionSchedule) return `${fmt(capital)} 一次性`;
const inj = sc.injectionSchedule;
// The index lists the injection amount in base capital; scale to this market.
const amount = sc.capital ? (inj.amount * capital) / sc.capital : inj.amount;
if (inj.frequency === "yearly_months" && inj.months?.length) {
const monthLabel = inj.months.join("/");
return `${fmt(capital)} 起 · 每年 ${monthLabel} 月注入 ${fmt(amount)}`;
}
const freqLabel = inj.frequency === "monthly" ? "每月" : inj.frequency === "quarterly" ? "每季" : "每年";
return `${fmt(capital)} 起 · ${freqLabel}注入 ${fmt(amount)}`;
}
// Older precomputed files baked USD amounts into scenario copy; don't show
// that copy for non-US markets.
function hasUsdCopy(text: string | undefined): boolean {
return !!text && /\$|美元|USD/.test(text);
}
function scenarioLabel(sc: PrecomputedScenarioInfo, market: StrategyMarket): string {
if (market === "us" || !hasUsdCopy(sc.label)) return sc.label;
return sc.injectionSchedule ? "起始资金 + 定期追加" : "起始资金一次性投入";
}
function scenarioDescription(sc: PrecomputedScenarioInfo, market: StrategyMarket): string {
if (sc.description && (market === "us" || !hasUsdCopy(sc.description))) return sc.description;
return summarizeScenario(sc, market);
}
function categoryLabel(cat: string): string {
if (cat === "all") return "全部";
if (cat === "stock_picker") return "选股";
if (cat === "factor_combo") return "因子";
if (cat === "tactical_allocation") return "配置";
if (cat === "other") return "其他";
return cat;
}
const pct = formatPct;
const num = formatNumber;
function finiteOrUndefined(val: number | null | undefined): number | undefined {
return typeof val === "number" && Number.isFinite(val) ? val : undefined;
}
function isPositive(val: number | null | undefined): boolean {
return (finiteOrUndefined(val) ?? 0) > 0;
}
function isNegative(val: number | null | undefined): boolean {
return (finiteOrUndefined(val) ?? 0) < 0;
}
function isAbove(val: number | null | undefined, threshold: number): boolean {
const v = finiteOrUndefined(val);
return v !== undefined && v > threshold;
}
function signClass(val: number | null | undefined): string {
const v = finiteOrUndefined(val);
if (v === undefined) return "";
return v >= 0 ? "positive" : "negative";
}
function RuinedBadge() {
return (
<span className="ruinedBadge" title="资产曾归零,结果不可作为可行策略参考">
爆仓/不可行
</span>
);
}
function displayStrategyName(strategy: Pick<StrategyInfo | StrategyResult, "name" | "label">): string {
const label = typeof strategy.label === "string" ? strategy.label.trim() : "";
return label || strategy.name;
}
function heatColor(val: number | undefined): string {
if (val === undefined) return "transparent";
if (val > 0) {
const intensity = Math.min(val / 0.15, 1);
return `rgba(38, 166, 154, ${0.15 + intensity * 0.6})`;
}
const intensity = Math.min(Math.abs(val) / 0.15, 1);
return `rgba(239, 83, 80, ${0.15 + intensity * 0.6})`;
}

1248
web/src/client/main.tsx Normal file

File diff suppressed because it is too large Load Diff

2505
web/src/client/styles.css Normal file

File diff suppressed because it is too large Load Diff

352
web/src/server/index.ts Normal file
View File

@@ -0,0 +1,352 @@
import "dotenv/config";
import cors from "cors";
import express from "express";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { generateDailyReport } from "../agent/report";
import { createProvider } from "../agent/provider";
import { normalizeSymbol } from "../agent/symbols";
import { StockDataCache } from "../agent/stockDataCache";
import { WatchlistStore } from "../agent/watchlistStore";
import { loadIpadsConfig } from "./ipadsConfig";
import type { ChartPeriod, WatchStock } from "../shared/types";
import type { BacktestConfig } from "../shared/strategyTypes";
import { listStrategies, runBacktest, getCachedResult, getLiveStrategies } from "./strategyService";
const port = Number(process.env.PORT ?? 8787);
const host = process.env.HOST ?? "127.0.0.1";
const dataDir = process.env.STOCK_AGENT_DATA_DIR ?? join(process.cwd(), "data");
const watchlistStore = new WatchlistStore(join(dataDir, "watchlist.json"));
const dataProvider = createProvider(loadIpadsConfig().apiKey);
const stockDataCache = new StockDataCache(join(dataDir, "cache"), dataProvider);
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, parseTags(request.body?.tags)));
cachedReport = undefined;
} catch (error) {
next(error);
}
});
app.put("/api/watchlist/:symbol/tags", async (request, response, next) => {
try {
const updated = await watchlistStore.updateTags(request.params.symbol, parseTags(request.body?.tags));
cachedReport = undefined;
response.json(updated);
} 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/stocks/:symbol/chart", async (request, response, next) => {
try {
const stock = await findWatchStock(request.params.symbol);
response.json(
request.query.refresh === "1"
? await stockDataCache.refreshChart(stock, parsePeriod(request.query.period))
: await stockDataCache.chart(stock, parsePeriod(request.query.period))
);
} catch (error) {
next(error);
}
});
app.get("/api/stocks/:symbol/fundamentals", async (request, response, next) => {
try {
response.json(await stockDataCache.fundamentals(await findWatchStock(request.params.symbol), request.query.refresh === "1"));
} catch (error) {
next(error);
}
});
app.get("/api/stocks/:symbol/related", async (request, response, next) => {
try {
response.json(await stockDataCache.relatedData(await findWatchStock(request.params.symbol), request.query.refresh === "1"));
} catch (error) {
next(error);
}
});
app.get("/api/stocks/:symbol/analysis", async (request, response, next) => {
try {
const stock = await findWatchStock(request.params.symbol);
if (request.query.refresh !== "1" && (await stockDataCache.hasAnalysis(stock))) {
const analysis = await stockDataCache.quantAnalysis(stock);
if (await stockDataCache.analysisNeedsRefresh(stock)) {
void stockDataCache.quantAnalysis(stock, true).catch((error) => {
console.error(`background analysis refresh failed for ${stock.display}`, error);
});
}
response.json(analysis);
return;
}
if (request.query.refresh !== "1") {
void stockDataCache.quantAnalysis(stock, true).catch((error) => {
console.error(`background analysis generation failed for ${stock.display}`, error);
});
response.json(stockDataCache.placeholderAnalysis(stock));
return;
}
response.json(await stockDataCache.quantAnalysis(stock, request.query.refresh === "1"));
} 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(true));
} catch (error) {
next(error);
}
});
// ---------------------------------------------------------------------------
// Strategy / Quant endpoints
// ---------------------------------------------------------------------------
app.get("/api/strategies", async (_request, response, next) => {
try {
response.json(await listStrategies());
} catch (error) {
next(error);
}
});
function loadPrecomputedFile() {
const precomputedPath = join(dataDir, "precomputed", "backtest_results.json");
if (!existsSync(precomputedPath)) {
throw new Error("Pre-computed results not available. Run precompute_backtest.py first.");
}
return JSON.parse(readFileSync(precomputedPath, "utf-8"));
}
// Newer files expose `scenarios`; legacy files expose `markets` at the top level.
function resolveScenarios(raw: { scenarios?: Record<string, unknown>; markets?: Record<string, unknown> }) {
if (raw.scenarios && typeof raw.scenarios === "object") {
return raw.scenarios as Record<string, {
id: string;
label: string;
description?: string;
capital: number;
capitalByMarket?: Record<string, number>;
injectionSchedule: unknown;
markets: Record<string, unknown>;
}>;
}
// Backward compat — wrap legacy single-scenario file as one anonymous scenario.
if (raw.markets) {
return {
legacy: {
id: "legacy",
label: "默认",
capital: 100000,
injectionSchedule: null,
markets: raw.markets,
},
} as Record<string, {
id: string;
label: string;
description?: string;
capital: number;
capitalByMarket?: Record<string, number>;
injectionSchedule: unknown;
markets: Record<string, unknown>;
}>;
}
throw new Error("Pre-computed file is missing both `scenarios` and `markets`.");
}
app.get("/api/strategies/precomputed/index", (_request, response, next) => {
try {
const raw = loadPrecomputedFile();
const scenarios = resolveScenarios(raw);
const list = Object.values(scenarios).map((sc) => ({
id: sc.id,
label: sc.label,
description: sc.description,
capital: sc.capital,
capitalByMarket: sc.capitalByMarket,
injectionSchedule: sc.injectionSchedule ?? null,
availableMarkets: Object.keys(sc.markets ?? {}),
}));
response.json({ computedAt: raw.computedAt, scenarios: list });
} catch (error) {
next(error);
}
});
app.get("/api/strategies/precomputed", (_request, response, next) => {
try {
const market = String(_request.query.market ?? "us");
const requestedScenario = _request.query.scenario ? String(_request.query.scenario) : undefined;
const raw = loadPrecomputedFile();
const scenarios = resolveScenarios(raw);
const scenarioIds = Object.keys(scenarios);
// An explicit but unknown scenario is a client error; silently serving
// another scenario would mislabel the results.
if (requestedScenario && !Object.hasOwn(scenarios, requestedScenario)) {
throw new Error(`Unknown pre-computed scenario: ${requestedScenario}`);
}
const scenarioId = requestedScenario ?? scenarioIds[0];
const scenario = scenarios[scenarioId];
if (!scenario) {
throw new Error("No pre-computed scenarios available.");
}
const marketResult = scenario.markets?.[market];
if (!marketResult) {
throw new Error(`No pre-computed results for market: ${market} in scenario ${scenarioId}`);
}
response.json({
id: `precomputed_${scenarioId}_${market}`,
scenarioId,
computedAt: raw.computedAt,
...(marketResult as object),
});
} catch (error) {
next(error);
}
});
app.post("/api/strategies/backtest", async (request, response, next) => {
try {
const config = request.body as BacktestConfig;
if (!config.strategies?.length) throw new Error("strategies is required");
if (!config.market) throw new Error("market is required");
const result = await runBacktest({
strategies: config.strategies,
market: config.market,
years: config.years ?? 10,
initialCapital: config.initialCapital ?? 100000,
fixedFee: config.fixedFee ?? 2.0,
txCost: config.txCost ?? 0.001,
injectionSchedule: config.injectionSchedule,
});
response.json(result);
} catch (error) {
next(error);
}
});
app.get("/api/strategies/results/:id", async (request, response, next) => {
try {
const result = await getCachedResult(request.params.id);
if (!result) throw new Error("Result not found");
response.json(result);
} catch (error) {
next(error);
}
});
app.get("/api/strategies/live", async (request, response, next) => {
try {
const market = String(request.query.market ?? "us");
const strategy = request.query.strategy ? String(request.query.strategy) : undefined;
response.json(await getLiveStrategies(market, strategy));
} 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, host, () => {
console.log(`stock agent api listening at http://${host}:${port}`);
});
async function getDailyReport(forceSnapshots = false) {
const now = Date.now();
if (!forceSnapshots && cachedReport && now - cachedReportUpdatedAt < 24 * 60 * 60 * 1000) {
return cachedReport;
}
const watchlist = await watchlistStore.list();
cachedReport = await generateDailyReport(
watchlist,
{
snapshot: (stock) => stockDataCache.snapshot(stock, forceSnapshots),
candles: (stock) => dataProvider.candles(stock),
fundamentals: (stock) => dataProvider.fundamentals(stock),
relatedData: (stock) => dataProvider.relatedData(stock),
quantAnalysis: (input) => dataProvider.quantAnalysis(input)
},
new Date()
);
cachedReportUpdatedAt = now;
return cachedReport;
}
async function findWatchStock(symbolInput: string): Promise<WatchStock> {
const normalized = normalizeSymbol(symbolInput);
const stock = (await watchlistStore.list()).find((item) => item.display === normalized.display);
if (!stock) {
throw new Error("Stock is not in watchlist");
}
return stock;
}
function parseTags(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map(String);
}
if (typeof value === "string") {
return value.split(",");
}
return [];
}
function parsePeriod(value: unknown): ChartPeriod {
return value === "W" || value === "M" || value === "Y" ? value : "D";
}

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)
};
}

View File

@@ -0,0 +1,178 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync } from "node:fs";
import { join, resolve } from "node:path";
import type { BacktestConfig, BacktestResult, LiveStrategyState, StrategyInfo } from "../shared/strategyTypes.js";
const QUANT_DIR = resolve(process.env.QUANT_PROJECT_DIR || join(process.cwd(), "../quant"));
const CACHE_DIR = join(process.env.STOCK_AGENT_DATA_DIR || join(process.cwd(), "data"), "strategy_cache");
const TIMEOUT_MS = Number(process.env.QUANT_BACKTEST_TIMEOUT_MS) || 180_000;
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_CACHE_FILES = 50;
function ensureCacheDir(): void {
if (!existsSync(CACHE_DIR)) {
mkdirSync(CACHE_DIR, { recursive: true });
}
}
function configHash(config: BacktestConfig): string {
const serialized = stableStringify(config);
return createHash("md5").update(serialized).digest("hex").slice(0, 12);
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify((value as Record<string, unknown>)[key])}`)
.join(",")}}`;
}
return JSON.stringify(value);
}
function runBridge(args: string[]): Promise<unknown> {
return new Promise((resolve, reject) => {
const proc = spawn("uv", ["run", "python", "bridge.py", ...args], {
cwd: QUANT_DIR,
env: { ...process.env, PYTHONUNBUFFERED: "1" },
});
const chunks: Buffer[] = [];
let stderr = "";
let exitCode: number | null = null;
let stdoutEnded = false;
let exited = false;
function tryFinish() {
if (!stdoutEnded || !exited) return;
clearTimeout(timer);
const stdout = Buffer.concat(chunks).toString("utf-8");
if (exitCode !== 0) {
reject(new Error(`bridge.py exited with code ${exitCode}: ${stderr.slice(-1000)}`));
} else {
try {
resolve(JSON.parse(stdout));
} catch {
reject(new Error(`Failed to parse bridge output (${stdout.length} bytes): ${stdout.slice(0, 200)}`));
}
}
}
proc.stdout.on("data", (chunk: Buffer) => { chunks.push(chunk); });
proc.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
proc.stdout.on("end", () => { stdoutEnded = true; tryFinish(); });
proc.on("close", (code) => { exitCode = code; exited = true; tryFinish(); });
const timer = setTimeout(() => {
proc.kill("SIGTERM");
reject(new Error(`Bridge timeout after ${TIMEOUT_MS}ms. stderr: ${stderr.slice(-500)}`));
}, TIMEOUT_MS);
proc.on("error", (err) => {
clearTimeout(timer);
reject(new Error(`Failed to spawn bridge.py: ${err.message}`));
});
});
}
function evictOldCache(): void {
ensureCacheDir();
const files = readdirSync(CACHE_DIR)
.filter((f) => f.endsWith(".json"))
.map((f) => ({ name: f, mtime: statSync(join(CACHE_DIR, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
while (files.length > MAX_CACHE_FILES) {
const old = files.pop()!;
unlinkSync(join(CACHE_DIR, old.name));
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
let cachedStrategies: { data: StrategyInfo[]; fetchedAt: number } | null = null;
export async function listStrategies(): Promise<StrategyInfo[]> {
if (cachedStrategies && Date.now() - cachedStrategies.fetchedAt < CACHE_TTL_MS) {
return cachedStrategies.data;
}
const data = (await runBridge(["list"])) as StrategyInfo[];
cachedStrategies = { data, fetchedAt: Date.now() };
return data;
}
export async function runBacktest(config: BacktestConfig): Promise<BacktestResult> {
const id = configHash(config);
ensureCacheDir();
// Check cache
const cachePath = join(CACHE_DIR, `${id}.json`);
if (existsSync(cachePath)) {
const stat = statSync(cachePath);
if (Date.now() - stat.mtimeMs < CACHE_TTL_MS) {
return normalizeBacktestResult(JSON.parse(readFileSync(cachePath, "utf-8")) as BacktestResult);
}
}
// Run backtest
const args = [
"backtest",
"--strategies", config.strategies.join(","),
"--market", config.market,
"--years", String(config.years),
"--capital", String(config.initialCapital),
"--fixed-fee", String(config.fixedFee),
"--tx-cost", String(config.txCost),
];
if (config.injectionSchedule) {
args.push("--injection", JSON.stringify(config.injectionSchedule));
}
const raw = await runBridge(args);
const result = normalizeBacktestResult({
id,
...(raw as Omit<BacktestResult, "id" | "computedAt">),
computedAt: new Date().toISOString(),
});
// Cache result
writeFileSync(cachePath, `${JSON.stringify(result)}\n`);
evictOldCache();
return result;
}
export async function getCachedResult(id: string): Promise<BacktestResult | null> {
ensureCacheDir();
const cachePath = join(CACHE_DIR, `${id}.json`);
if (!existsSync(cachePath)) return null;
return normalizeBacktestResult(JSON.parse(readFileSync(cachePath, "utf-8")) as BacktestResult);
}
export async function getLiveStrategies(market: string, strategy?: string): Promise<LiveStrategyState[]> {
const args = ["live", "--market", market];
if (strategy) args.push("--strategy", strategy);
return (await runBridge(args)) as LiveStrategyState[];
}
function normalizeBacktestResult(result: BacktestResult): BacktestResult {
return {
...result,
strategies: result.strategies.map((strategy) => ({
...strategy,
label: typeof strategy.label === "string" && strategy.label.trim() ? strategy.label : strategy.name
})),
benchmark: {
name: result.benchmark?.name || "Benchmark",
equityCurve: result.benchmark?.equityCurve ?? []
}
};
}

View File

@@ -0,0 +1,232 @@
export type StrategyMarket = "us" | "cn";
export type StrategyCurrency = "USD" | "CNY";
export type StrategyCategory = "stock_picker" | "tactical_allocation" | "factor_combo" | "other";
export interface StrategyParam {
type: "number" | "string" | "select";
default: number | string;
options?: string[];
min?: number;
max?: number;
}
export interface StrategyInfo {
name: string;
label: string;
category: StrategyCategory;
params: Record<string, StrategyParam>;
markets: StrategyMarket[];
}
export interface InjectionSchedule {
amount: number;
frequency: "yearly" | "quarterly" | "monthly" | "yearly_months";
startYear: number;
months?: number[];
}
export interface PrecomputedScenarioInfo {
id: string;
label: string;
description?: string;
capital: number;
// Per-market starting capital in that market's currency. Absent in older
// precomputed files, where `capital` applies to every market.
capitalByMarket?: Partial<Record<StrategyMarket, number>>;
injectionSchedule: InjectionSchedule | null;
availableMarkets: StrategyMarket[];
}
export interface PrecomputedIndex {
computedAt: string;
scenarios: PrecomputedScenarioInfo[];
}
export interface BacktestConfig {
strategies: string[];
market: StrategyMarket;
years: number;
initialCapital: number;
fixedFee: number;
txCost: number;
injectionSchedule?: InjectionSchedule;
}
export interface EquityPoint {
date: string;
value: number;
}
export interface DrawdownPeriod {
start: string;
end: string;
depth: number;
recoveryDate: string | null;
}
export interface TradeEvent {
date: string;
buys: number;
sells: number;
buyTickers: string[];
sellTickers: string[];
}
export interface InjectionEvent {
date: string;
amount: number;
}
// Backtest metrics are time-weighted (computed on the no-injection curve).
// The bridge serializes NaN as null, so every field may be null.
export interface StrategyMetrics {
totalReturn: number | null;
annualizedReturn: number | null;
annualizedVolatility: number | null;
sharpeRatio: number | null;
sortinoRatio: number | null;
// Negative fraction; closer to zero is better.
maxDrawdown: number | null;
calmarRatio: number | null;
winRate: number | null;
// Fraction of inter-rebalance periods with positive return. Null for
// strategies with fewer than two rebalances in the window (buy-and-hold).
rebalanceWinRate: number | null;
}
// Money view of a run with cash injections: what was put in vs. what it is worth.
export interface CapitalFlows {
invested: number;
finalValue: number;
profit: number;
returnOnInvested: number | null;
}
export interface StrategyResult {
name: string;
label: string;
// Money curve (includes injections when a schedule exists).
equityCurve: EquityPoint[];
// Same strategy without injections; metrics are computed on this curve.
baseEquityCurve: EquityPoint[] | null;
metrics: StrategyMetrics;
capitalFlows?: CapitalFlows;
// Equity hit zero at some point; the result is not investable.
ruined?: boolean;
drawdowns: DrawdownPeriod[];
trades: TradeEvent[];
injections: InjectionEvent[];
monthlyReturns: Record<string, number>;
yearlyReturns: Record<string, number>;
}
export interface BacktestMeta {
market: StrategyMarket;
years: number;
initialCapital: number;
startDate: string | null;
endDate: string | null;
txCost: number;
fixedFee: number;
injectionSchedule: InjectionSchedule | null;
}
export interface BacktestResult {
id: string;
// Set by the precomputed endpoint to the scenario actually served.
scenarioId?: string;
meta: BacktestMeta;
strategies: StrategyResult[];
benchmark: { name: string; equityCurve: EquityPoint[] };
computedAt: string;
}
export interface HoldingDetail {
ticker: string;
shares: number;
lastPrice: number | null;
marketValue: number | null;
// Latest panel price missing; lastPrice is the last recorded trade price.
priceStale?: boolean;
}
export interface TradeLogEntry {
date: string;
action: "BUY" | "SELL";
ticker: string;
shares: number;
price: number;
value: number;
commission: number;
}
export interface LiveStrategyState {
name: string;
label: string;
category: StrategyCategory | string;
market: StrategyMarket;
currency?: StrategyCurrency;
initialCapital: number;
// First live session; strategies may start on different dates.
startDate?: string | null;
currentValue: number;
cash: number;
marketValue: number;
unrealizedPnl: number;
unrealizedPnlPct: number;
holdings: Record<string, number>;
holdingsDetail: HoldingDetail[];
priceAsOf: string | null;
// Starts with an anchor point at initialCapital one day before startDate.
equityCurve: EquityPoint[];
// Market benchmark rebased to initialCapital over the strategy's sessions.
// Empty when the price panel lacks the benchmark.
benchmark?: { name: string; equityCurve: EquityPoint[] };
metrics: StrategyMetrics | null;
drawdowns: DrawdownPeriod[];
trades: TradeEvent[];
tradeLog: TradeLogEntry[];
monthlyReturns: Record<string, number>;
yearlyReturns: Record<string, number>;
lastUpdated: string | null;
}
// ---------------------------------------------------------------------------
// Display helpers shared by the strategy pages
// ---------------------------------------------------------------------------
const MARKET_CURRENCY: Record<StrategyMarket, StrategyCurrency> = { us: "USD", cn: "CNY" };
export function currencyForMarket(market: string | undefined): StrategyCurrency {
return MARKET_CURRENCY[market as StrategyMarket] ?? "USD";
}
export function formatMoney(value: number | null | undefined, currency: StrategyCurrency, fractionDigits = 0): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
return value.toLocaleString(undefined, {
style: "currency",
currency,
currencyDisplay: "narrowSymbol",
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
}
export function formatPct(value: number | null | undefined, fractionDigits = 1): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
return `${(value * 100).toFixed(fractionDigits)}%`;
}
export function formatNumber(value: number | null | undefined, fractionDigits = 2): string {
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
return value.toFixed(fractionDigits);
}
// Whole calendar days from a YYYY-MM-DD date to today (local time); null if unparseable.
export function calendarDaysSince(date: string | null | undefined, now: Date = new Date()): number | null {
const match = typeof date === "string" ? /^(\d{4})-(\d{2})-(\d{2})/.exec(date) : null;
if (!match) return null;
const then = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
return Math.round((today.getTime() - then.getTime()) / 86_400_000);
}

238
web/src/shared/types.ts Normal file
View File

@@ -0,0 +1,238 @@
export type Market = "CN" | "HK" | "US";
export type RecommendationAction = "buy_watch" | "hold" | "avoid";
export type ChartPeriod = "D" | "W" | "M" | "Y";
export interface NormalizedSymbol {
symbol: string;
market: Market;
display: string;
}
export interface WatchStock extends NormalizedSymbol {
id: string;
name?: string;
tags: string[];
addedAt: string;
}
export interface RelatedNews {
title: string;
url: string;
source?: string;
publishedAt?: string;
}
export interface DataSourceRef {
name: string;
url?: string;
asOf?: 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[];
asOf?: string;
sources?: DataSourceRef[];
}
export interface MarketDataProvider {
snapshot(stock: WatchStock): Promise<StockSnapshot>;
candles(stock: WatchStock, startDate?: string): Promise<PriceCandle[]>;
fundamentals(stock: WatchStock): Promise<FinancialStatement[]>;
relatedData(stock: WatchStock): Promise<RelatedStockData>;
quantAnalysis(input: QuantAnalysisInput): Promise<QuantAnalysis>;
}
export interface PriceCandle {
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface IndicatorPoint {
date: string;
ma5?: number;
ma20?: number;
ma60?: number;
ma120?: number;
ma200?: number;
rsi14?: number;
macd?: number;
macdSignal?: number;
macdHistogram?: number;
}
export interface StockChartData {
stock: WatchStock;
period: ChartPeriod;
candles: PriceCandle[];
indicators: IndicatorPoint[];
cachedAt: string;
}
export interface FinancialStatement {
period: string;
reportDate?: string;
revenue: number;
netIncome: number;
eps?: number;
grossMargin?: number;
operatingCashFlow?: number;
debtToAsset?: number;
}
export interface StockFundamentalsData {
stock: WatchStock;
statements: FinancialStatement[];
cachedAt: string;
}
export interface RelatedStockData {
sector?: string;
industryRank?: number;
beta?: number;
avgVolume20?: number;
volumeRatio?: number;
turnoverRate?: number;
volatility20?: number;
drawdownFromHigh?: number;
relativeStrength60?: number;
support?: number;
resistance?: number;
capitalFlow?: number;
sentimentScore?: number;
newsHeat?: number;
marketCap?: number;
enterpriseValue?: number;
sharesOutstanding?: number;
freeFloat?: number;
priceToBook?: number;
priceToSales?: number;
pegRatio?: number;
evToEbitda?: number;
dividendYield?: number;
freeCashFlowYield?: number;
grossMarginTtm?: number;
netMarginTtm?: number;
roeTtm?: number;
roaTtm?: number;
debtToEquity?: number;
currentRatio?: number;
revenueCagr3y?: number;
epsGrowthTtm?: number;
analystRating?: string;
analystTargetPrice?: number;
institutionalOwnership?: number;
insiderOwnership?: number;
shortInterestRatio?: number;
nextExDividendDate?: string;
indexMembership?: string[];
recentNews?: RelatedNews[];
socialMentions?: RelatedNews[];
socialDiscussionCount?: number;
webSentimentLabel?: string;
riskFlags: string[];
asOf?: string;
sources: DataSourceRef[];
}
export interface StockRelatedData {
stock: WatchStock;
data: RelatedStockData;
cachedAt: string;
}
export interface QuantFactor {
name: string;
score: number;
weight: number;
evidence: string;
}
export interface QuantAnalysis {
action: RecommendationAction;
score: number;
confidence: number;
horizon: string;
summary: string;
factors: QuantFactor[];
tradePlan?: QuantTradePlan;
marketContext?: string[];
riskNotes: string[];
}
export interface QuantTradePlan {
direction: "long" | "wait" | "reduce" | "avoid";
entryPrice?: number;
targetPrice?: number;
stopLoss?: number;
expectedReturnPct?: number;
invalidation?: string;
rationale: string;
}
export interface StockQuantAnalysis {
stock: WatchStock;
analysis: QuantAnalysis;
generatedAt: string;
cachedAt: string;
}
export interface QuantAnalysisInput {
stock: WatchStock;
snapshot: StockSnapshot;
candles: PriceCandle[];
indicators: IndicatorPoint[];
fundamentals: FinancialStatement[];
relatedData: RelatedStockData;
}
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[];
}

217
web/tests/provider.test.ts Normal file
View File

@@ -0,0 +1,217 @@
import { describe, expect, it, vi } from "vitest";
import { DemoMarketDataProvider, IPADS_BASE_URL, IPADS_WIRE_API, IpadsResponsesProvider } from "../src/agent/provider";
import { loadIpadsConfig } from "../src/server/ipadsConfig";
import type { WatchStock } from "../src/shared/types";
const stock: WatchStock = {
id: "AAPL",
symbol: "AAPL",
display: "AAPL",
market: "US",
name: "Apple",
tags: ["core"],
addedAt: "2026-05-11T00:00:00.000Z"
};
describe("IpadsResponsesProvider", () => {
it("uses the fixed IPADS Responses API endpoint for agent quant analysis", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
output: [
{
type: "message",
role: "assistant",
content: [
{
type: "output_text",
text: JSON.stringify({
action: "buy_watch",
score: 72,
confidence: 0.68,
horizon: "1-3 months",
summary: "Trend and valuation are supportive.",
factors: [
{ name: "trend", score: 75, weight: 0.4, evidence: "above MA20" },
{ name: "valuation", score: 68, weight: 0.3, evidence: "reasonable" },
{ name: "risk", score: 70, weight: 0.3, evidence: "controlled" }
],
riskNotes: []
})
}
]
}
]
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
});
const provider = new IpadsResponsesProvider("secret", fetchImpl);
const analysis = await provider.quantAnalysis({
stock,
snapshot: { price: 100, currency: "USD", pe: 20, peers: [], news: [] },
candles: [{ date: "2026-05-11", open: 100, high: 102, low: 99, close: 101, volume: 1_000_000 }],
indicators: [{ date: "2026-05-11", ma20: 98 }],
fundamentals: [],
relatedData: { riskFlags: [], sources: [] }
});
expect(fetchImpl).toHaveBeenCalledWith(
`${IPADS_BASE_URL}/${IPADS_WIRE_API}`,
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: "Bearer secret",
"Content-Type": "application/json"
}),
body: expect.stringContaining("\"model\":\"gpt-5.5\"")
})
);
const calls = fetchImpl.mock.calls as unknown as [string, RequestInit][];
const requestBody = JSON.parse(String(calls[0]?.[1].body));
expect(requestBody.input).toContain("\"symbol\":\"AAPL\"");
expect(typeof requestBody.input).toBe("string");
expect(requestBody).not.toHaveProperty("metadata");
expect(analysis.action).toBe("buy_watch");
});
it("falls back to finite local analysis when remote agent cannot analyze empty market data", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
output_text: JSON.stringify({
action: "hold",
score: 50,
confidence: 0.4,
horizon: "1-3个月",
summary: "invalid",
factors: [
{ name: "trend", score: 50, weight: 1, evidence: "empty" },
{ name: "valuation", score: 50, weight: 0, evidence: "empty" },
{ name: "risk", score: 50, weight: 0, evidence: "empty" }
],
tradePlan: {
direction: "wait",
entryPrice: null,
targetPrice: null,
stopLoss: null,
expectedReturnPct: null,
rationale: "invalid null prices"
},
riskNotes: []
})
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
});
const provider = new IpadsResponsesProvider("secret", fetchImpl);
const analysis = await provider.quantAnalysis({
stock: { ...stock, symbol: "DRAM", display: "DRAM", id: "DRAM" },
snapshot: { price: 0, currency: "USD", peers: [], news: [] },
candles: [],
indicators: [],
fundamentals: [],
relatedData: { riskFlags: [], sources: [] }
});
expect(Number.isFinite(analysis.score)).toBe(true);
expect(Number.isFinite(analysis.confidence)).toBe(true);
expect(analysis.factors.every((factor) => Number.isFinite(factor.score))).toBe(true);
expect(analysis.tradePlan?.entryPrice).toBeUndefined();
});
});
describe("loadIpadsConfig", () => {
it("reads IPADS_API_KEY from an env object and keeps fixed URL and wire API", () => {
expect(loadIpadsConfig({ IPADS_API_KEY: "secret" })).toEqual({
baseUrl: IPADS_BASE_URL,
wireApi: IPADS_WIRE_API,
apiKey: "secret",
apiKeyConfigured: true
});
});
it("reports an unconfigured API key when IPADS_API_KEY is blank", () => {
expect(loadIpadsConfig({ IPADS_API_KEY: " " })).toEqual({
baseUrl: IPADS_BASE_URL,
wireApi: IPADS_WIRE_API,
apiKey: "",
apiKeyConfigured: false
});
});
});
describe("DemoMarketDataProvider", () => {
it("returns deterministic snapshots when no provider URL is configured", async () => {
const provider = new DemoMarketDataProvider();
await expect(provider.snapshot(stock)).resolves.toEqual(
expect.objectContaining({
currency: "USD",
peers: expect.any(Array),
news: expect.any(Array)
})
);
});
it("returns chart candles and financial statements for local cached views", async () => {
const provider = new DemoMarketDataProvider();
await expect(provider.candles(stock)).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
date: expect.any(String),
open: expect.any(Number),
high: expect.any(Number),
low: expect.any(Number),
close: expect.any(Number),
volume: expect.any(Number)
})
])
);
await expect(provider.fundamentals(stock)).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
period: expect.any(String),
revenue: expect.any(Number),
netIncome: expect.any(Number)
})
])
);
});
it("returns related data and a structured quantitative analysis", async () => {
const provider = new DemoMarketDataProvider();
const candles = await provider.candles(stock);
const relatedData = await provider.relatedData(stock);
expect(relatedData).toEqual(
expect.objectContaining({
sector: expect.any(String),
avgVolume20: expect.any(Number),
sentimentScore: expect.any(Number)
})
);
await expect(
provider.quantAnalysis({
stock,
snapshot: await provider.snapshot(stock),
candles,
indicators: [],
fundamentals: await provider.fundamentals(stock),
relatedData
})
).resolves.toEqual(
expect.objectContaining({
action: expect.stringMatching(/buy_watch|hold|avoid/),
score: expect.any(Number),
factors: expect.any(Array),
tradePlan: expect.objectContaining({
rationale: expect.any(String)
})
})
);
});
});

116
web/tests/report.test.ts Normal file
View File

@@ -0,0 +1,116 @@
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",
tags: ["core"],
addedAt: "2026-05-11T00:00:00.000Z"
},
{
id: "00700.HK",
symbol: "00700",
display: "00700.HK",
market: "HK",
name: "Tencent",
tags: ["internet"],
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: []
};
},
async candles() {
return [];
},
async fundamentals() {
return [];
},
async relatedData() {
return {
sector: "Technology",
avgVolume20: 1_000_000,
volumeRatio: 1,
volatility20: 0.02,
drawdownFromHigh: 0.1,
relativeStrength60: 0.04,
support: 90,
resistance: 120,
capitalFlow: 0.1,
sentimentScore: 0.6,
newsHeat: 0.5,
riskFlags: [],
sources: []
};
},
async quantAnalysis() {
return {
action: "hold",
score: 55,
confidence: 0.5,
horizon: "1-3 months",
summary: "Hold",
factors: [
{ name: "trend", score: 55, weight: 1, evidence: "neutral" },
{ name: "valuation", score: 55, weight: 0, evidence: "neutral" },
{ name: "risk", score: 55, weight: 0, evidence: "neutral" }
],
riskNotes: []
};
}
};
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");
});
});

View File

@@ -0,0 +1,179 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StockDataCache } from "../src/agent/stockDataCache";
import type { MarketDataProvider, WatchStock } from "../src/shared/types";
let tempDir: string | undefined;
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { force: true, recursive: true });
tempDir = undefined;
}
});
const stock: WatchStock = {
id: "AAPL",
symbol: "AAPL",
display: "AAPL",
market: "US",
name: "Apple",
tags: ["core"],
addedAt: "2026-05-11T00:00:00.000Z"
};
describe("StockDataCache", () => {
it("persists chart and fundamentals data and reuses the local cache", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-cache-"));
const provider: MarketDataProvider = {
snapshot: vi.fn(async () => ({
price: 100,
currency: "USD",
pe: 20,
peers: [],
news: []
})),
candles: vi.fn(async () =>
Array.from({ length: 1010 }, (_, index) => ({
date: new Date(Date.UTC(2022, 0, 3 + index)).toISOString().slice(0, 10),
open: 100 + index,
high: 102 + index,
low: 99 + index,
close: 101 + index,
volume: 1_000_000 + index
}))
),
fundamentals: vi.fn(async () => [
{
period: "2026Q1",
revenue: 1_000_000,
netIncome: 120_000
}
]),
relatedData: vi.fn(async () => ({
sector: "Technology",
avgVolume20: 1_000_000,
volumeRatio: 1.1,
volatility20: 0.02,
drawdownFromHigh: 0.08,
relativeStrength60: 0.05,
support: 98,
resistance: 112,
capitalFlow: 0.1,
sentimentScore: 0.6,
newsHeat: 0.5,
riskFlags: [],
sources: []
})),
quantAnalysis: vi.fn(async () => ({
action: "buy_watch" as const,
score: 72,
confidence: 0.7,
horizon: "1-3 months",
summary: "Positive",
factors: [
{ name: "trend", score: 70, weight: 0.4, evidence: "above average" },
{ name: "valuation", score: 68, weight: 0.3, evidence: "reasonable" },
{ name: "sentiment", score: 74, weight: 0.3, evidence: "supportive" }
],
riskNotes: []
}))
};
const cache = new StockDataCache(tempDir, provider, () => new Date("2026-05-11T00:00:00.000Z"));
await cache.chart(stock);
await cache.chart(stock);
await cache.fundamentals(stock);
await cache.fundamentals(stock);
expect(provider.candles).toHaveBeenCalledTimes(1);
expect(provider.fundamentals).toHaveBeenCalledTimes(1);
await expect(cache.snapshot(stock)).resolves.toEqual(expect.objectContaining({ price: 100 }));
await expect(cache.snapshot(stock)).resolves.toEqual(expect.objectContaining({ price: 100 }));
expect(provider.snapshot).toHaveBeenCalledTimes(1);
await expect(cache.chart(stock, "W")).resolves.toEqual(expect.objectContaining({ period: "W" }));
await expect(cache.relatedData(stock)).resolves.toEqual(expect.objectContaining({ data: expect.objectContaining({ sector: "Technology" }) }));
await expect(cache.quantAnalysis(stock)).resolves.toEqual(expect.objectContaining({ analysis: expect.objectContaining({ score: 72 }) }));
await expect(cache.quantAnalysis(stock)).resolves.toEqual(expect.objectContaining({ analysis: expect.objectContaining({ score: 72 }) }));
expect(provider.quantAnalysis).toHaveBeenCalledTimes(1);
});
it("accepts real short listing history instead of requiring a full ten years", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-cache-"));
const provider: MarketDataProvider = {
snapshot: vi.fn(async () => ({ price: 18, currency: "USD", peers: [], news: [] })),
candles: vi.fn(async () =>
Array.from({ length: 225 }, (_, index) => ({
date: new Date(Date.UTC(2025, 5, 18 + index)).toISOString().slice(0, 10),
open: 18 + index / 100,
high: 19 + index / 100,
low: 17 + index / 100,
close: 18.5 + index / 100,
volume: 800_000 + index
}))
),
fundamentals: vi.fn(async () => []),
relatedData: vi.fn(async () => ({ riskFlags: [], sources: [] })),
quantAnalysis: vi.fn(async () => ({
action: "hold" as const,
score: 58,
confidence: 0.6,
horizon: "1-3个月",
summary: "等待确认",
factors: [
{ name: "趋势", score: 58, weight: 0.4, evidence: "短历史可用" },
{ name: "估值", score: 50, weight: 0.3, evidence: "数据不足" },
{ name: "风险", score: 55, weight: 0.3, evidence: "控制仓位" }
],
riskNotes: []
}))
};
const cache = new StockDataCache(tempDir, provider, () => new Date("2026-05-12T00:00:00.000Z"));
await expect(cache.chart({ ...stock, id: "SLDE", symbol: "SLDE", display: "SLDE" })).resolves.toEqual(
expect.objectContaining({
candles: expect.arrayContaining([expect.objectContaining({ date: expect.any(String) })])
})
);
expect(provider.candles).toHaveBeenCalledTimes(1);
});
it("caches an empty historical result without treating it as an error", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-cache-"));
const provider: MarketDataProvider = {
snapshot: vi.fn(async () => ({ price: 18, currency: "USD", peers: [], news: [] })),
candles: vi.fn(async () => []),
fundamentals: vi.fn(async () => []),
relatedData: vi.fn(async () => ({ riskFlags: [], sources: [] })),
quantAnalysis: vi.fn(async () => ({
action: "hold" as const,
score: 50,
confidence: 0.4,
horizon: "1-3个月",
summary: "无K线数据,忽略技术面。",
factors: [
{ name: "趋势", score: 50, weight: 0.4, evidence: "无历史数据" },
{ name: "估值", score: 50, weight: 0.3, evidence: "数据不足" },
{ name: "风险", score: 50, weight: 0.3, evidence: "等待数据" }
],
riskNotes: []
}))
};
const cache = new StockDataCache(tempDir, provider, () => new Date("2026-05-12T00:00:00.000Z"));
await expect(cache.chart({ ...stock, id: "NEW", symbol: "NEW", display: "NEW" })).resolves.toEqual(
expect.objectContaining({
candles: [],
indicators: []
})
);
await expect(cache.chart({ ...stock, id: "NEW", symbol: "NEW", display: "NEW" })).resolves.toEqual(
expect.objectContaining({
candles: []
})
);
expect(provider.candles).toHaveBeenCalledTimes(1);
});
});

21
web/tests/symbols.test.ts Normal file
View File

@@ -0,0 +1,21 @@
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" }],
["512480", { symbol: "512480", market: "CN", display: "512480.SS" }],
["512480.SZ", { symbol: "512480", market: "CN", display: "512480.SS" }],
["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,63 @@
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", ["Core", " AI ", "Core"]);
await store.add("AAPL", "Duplicate");
await store.add("00700.HK", "Tencent");
expect(await store.list()).toEqual([
expect.objectContaining({ display: "AAPL", market: "US", name: "Apple", tags: ["Core", "AI"] }),
expect.objectContaining({ display: "00700.HK", market: "HK", name: "Tencent", tags: [] })
]);
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([]);
});
it("updates tags for a persisted stock", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-"));
const store = new WatchlistStore(join(tempDir, "watchlist.json"));
await store.add("MSFT", "Microsoft");
await store.updateTags("MSFT.US", ["cloud", "quality"]);
expect((await store.list())[0]).toEqual(expect.objectContaining({ tags: ["cloud", "quality"] }));
});
it("canonicalizes persisted China ETF exchange suffixes", async () => {
tempDir = await mkdtemp(join(tmpdir(), "stock-agent-"));
const store = new WatchlistStore(join(tempDir, "watchlist.json"));
await store.add("512480.SZ", "Semiconductor ETF");
expect((await store.list())[0]).toEqual(expect.objectContaining({ display: "512480.SS", id: "512480.SS" }));
});
});

20
web/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"]
}

18
web/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const host = process.env.HOST ?? "127.0.0.1";
const clientPort = Number(process.env.CLIENT_PORT ?? 5173);
const apiPort = Number(process.env.PORT ?? 8787);
export default defineConfig({
plugins: [react()],
server: {
host,
port: clientPort,
allowedHosts: ["gahow-pc.ipads-lab.se.sjtu.edu.cn"],
proxy: {
"/api": `http://127.0.0.1:${apiPort}`
}
}
});