feat(ui): split client into Live and Strategy pages and wire new routes
Rebuilds the client into Live (watchlist with charts, fundamentals, news, quant analysis) and Strategy (backtest runner / precomputed results) pages, adds the corresponding /api/stocks/:symbol/chart|fundamentals|related|quant and /api/strategies endpoints, and updates project tooling. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
24
README.md
24
README.md
@@ -13,6 +13,14 @@ Frontend: http://127.0.0.1:5173
|
|||||||
|
|
||||||
Backend: http://127.0.0.1:8787
|
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
|
## IPADS API
|
||||||
|
|
||||||
配置文件:
|
配置文件:
|
||||||
@@ -44,3 +52,19 @@ The response output text must be JSON matching the stock snapshot schema used by
|
|||||||
- Normalize symbols into A股、港股、美股 markets.
|
- 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.
|
- 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.
|
- 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/`.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Personal Stock Agent</title>
|
<title>Personal Stock Monitor</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -12,6 +12,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"lightweight-charts": "^5.2.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
@@ -2041,6 +2042,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fancy-canvas": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -2332,6 +2339,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lightweight-charts": {
|
||||||
|
"version": "5.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.2.0.tgz",
|
||||||
|
"integrity": "sha512-ey3Vas8UhV06ni+LT9TA1nEe4y8So4Mi6CL/oarNHFMyTktz/xy8e8+oh04Q//eO3t6etvFXgayz2fClyFQb5w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"fancy-canvas": "2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/loupe": {
|
"node_modules/loupe": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"",
|
"dev": "concurrently \"npm:dev:server\" \"npm:dev:client\"",
|
||||||
"dev:server": "tsx watch src/server/index.ts",
|
"dev:server": "tsx watch src/server/index.ts",
|
||||||
"dev:client": "vite --host 127.0.0.1",
|
"dev:client": "vite",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"build": "tsc --noEmit && vite build"
|
"build": "tsc --noEmit && vite build"
|
||||||
},
|
},
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"lightweight-charts": "^5.2.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
|||||||
606
src/client/LivePage.tsx
Normal file
606
src/client/LivePage.tsx
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
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 type {
|
||||||
|
LiveStrategyState,
|
||||||
|
StrategyMarket,
|
||||||
|
TradeLogEntry,
|
||||||
|
} from "../shared/strategyTypes";
|
||||||
|
|
||||||
|
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="重新拉取最新行情和持仓"
|
||||||
|
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)}</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}</span>
|
||||||
|
<span>{s.lastUpdated ?? "-"}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="liveDetail">
|
||||||
|
{focused ? (
|
||||||
|
<LiveStrategyDetail strategy={focused} />
|
||||||
|
) : (
|
||||||
|
<div className="panel emptyState">
|
||||||
|
<Wallet size={40} />
|
||||||
|
<p>未选择策略</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveStrategyDetail({ strategy }: { strategy: LiveStrategyState }) {
|
||||||
|
const m = strategy;
|
||||||
|
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()} · 初始 {money(m.initialCapital)} · 更新于 {m.lastUpdated ?? "-"}
|
||||||
|
{m.priceAsOf && m.priceAsOf !== m.lastUpdated ? ` · 价格 ${m.priceAsOf}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`liveTotalPnl ${m.unrealizedPnl >= 0 ? "positive" : "negative"}`}>
|
||||||
|
<strong>{money(m.currentValue)}</strong>
|
||||||
|
<span>
|
||||||
|
{m.unrealizedPnl >= 0 ? "+" : ""}
|
||||||
|
{money(m.unrealizedPnl)} ({pct(m.unrealizedPnlPct)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="liveSummaryGrid">
|
||||||
|
<SummaryCell label="现金" value={money(m.cash)} />
|
||||||
|
<SummaryCell label="持仓市值" value={money(m.marketValue)} />
|
||||||
|
<SummaryCell label="总资产" value={money(m.currentValue)} />
|
||||||
|
<SummaryCell label="持仓数" value={`${Object.keys(m.holdings).length}`} />
|
||||||
|
<SummaryCell label="累计交易" value={`${m.tradeLog.length}`} />
|
||||||
|
{m.metrics && <SummaryCell label="Sharpe" value={m.metrics.sharpeRatio.toFixed(2)} />}
|
||||||
|
{m.metrics && <SummaryCell label="最大回撤" value={pct(m.metrics.maxDrawdown)} negative />}
|
||||||
|
{m.metrics && <SummaryCell label="日胜率" value={pct(m.metrics.winRate)} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LiveEquityChart equityCurve={m.equityCurve} initialCapital={m.initialCapital} />
|
||||||
|
|
||||||
|
<HoldingsPanel
|
||||||
|
holdings={m.holdingsDetail}
|
||||||
|
cash={m.cash}
|
||||||
|
currentValue={m.currentValue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TradeLogPanel log={m.tradeLog} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}: {
|
||||||
|
equityCurve: { date: string; value: number }[];
|
||||||
|
initialCapital: number;
|
||||||
|
}) {
|
||||||
|
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" },
|
||||||
|
});
|
||||||
|
chartRef.current = chart;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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}`
|
||||||
|
: ""}
|
||||||
|
</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,
|
||||||
|
}: {
|
||||||
|
holdings: LiveStrategyState["holdingsDetail"];
|
||||||
|
cash: number;
|
||||||
|
currentValue: number;
|
||||||
|
}) {
|
||||||
|
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>{h.lastPrice !== null ? `$${h.lastPrice.toFixed(2)}` : "-"}</td>
|
||||||
|
<td>{h.marketValue !== null ? money(h.marketValue) : "-"}</td>
|
||||||
|
<td>{pct(weight)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="holdingsCashRow">
|
||||||
|
<td>
|
||||||
|
<strong>现金</strong>
|
||||||
|
</td>
|
||||||
|
<td>-</td>
|
||||||
|
<td>-</td>
|
||||||
|
<td>{money(cash)}</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 }: { log: TradeLogEntry[] }) {
|
||||||
|
// 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>${t.price.toFixed(2)}</td>
|
||||||
|
<td>{money(t.value)}</td>
|
||||||
|
<td>{money(t.commission)}</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(v: number): string {
|
||||||
|
if (typeof v !== "number" || !Number.isFinite(v)) return "-";
|
||||||
|
return v.toLocaleString(undefined, {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(v: number): string {
|
||||||
|
if (typeof v !== "number" || !Number.isFinite(v)) return "-";
|
||||||
|
return `${(v * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
850
src/client/StrategyPage.tsx
Normal file
850
src/client/StrategyPage.tsx
Normal file
@@ -0,0 +1,850 @@
|
|||||||
|
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 type {
|
||||||
|
BacktestResult,
|
||||||
|
PrecomputedIndex,
|
||||||
|
PrecomputedScenarioInfo,
|
||||||
|
StrategyInfo,
|
||||||
|
StrategyMarket,
|
||||||
|
StrategyMetrics,
|
||||||
|
StrategyResult,
|
||||||
|
} from "../shared/strategyTypes";
|
||||||
|
|
||||||
|
const STRATEGY_COLORS = [
|
||||||
|
"#4db6ff", "#f3c969", "#c084fc", "#26a69a", "#ef5350",
|
||||||
|
"#ff9800", "#8bc34a", "#e91e63", "#00bcd4", "#cddc39",
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_CHART_SELECTION = 10;
|
||||||
|
|
||||||
|
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" },
|
||||||
|
{ key: "maxDrawdown", label: "MaxDD", better: "low" },
|
||||||
|
{ 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
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const currentScenario = useMemo(
|
||||||
|
() => scenarioList.find((s) => s.id === scenarioId) ?? null,
|
||||||
|
[scenarioList, scenarioId],
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="strategyPage">
|
||||||
|
{error && <div className="alert">{error}</div>}
|
||||||
|
{precomputedError && <div className="alert">{precomputedError}</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={sc.description ?? sc.label}
|
||||||
|
>
|
||||||
|
<strong>{sc.label}</strong>
|
||||||
|
<span>{summarizeScenario(sc)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{meta && (
|
||||||
|
<p className="precomputedHint">
|
||||||
|
{meta.years} 年 · 离线预计算
|
||||||
|
{computedAt ? ` · ${computedAt.toLocaleDateString()}` : ""}
|
||||||
|
</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 ? "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)}</span>
|
||||||
|
<span className={`strategyMetric ${s.metrics.annualizedReturn >= 0 ? "positive" : "negative"}`}>
|
||||||
|
{pct(s.metrics.annualizedReturn)}
|
||||||
|
</span>
|
||||||
|
<span className="strategyMetricMuted">
|
||||||
|
{s.metrics.sharpeRatio.toFixed(2)}
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
<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,
|
||||||
|
}: {
|
||||||
|
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>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="panel rankingPanel">
|
||||||
|
<div className="panelHeader">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Leaderboard</p>
|
||||||
|
<h2>策略排名</h2>
|
||||||
|
<p>点击行加入/移除对比;列头切换排序指标</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 ? "top" : ""}`}>{rank}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{displayStrategyName(s)}</strong>
|
||||||
|
<span className="rankSub">{s.name}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className="categoryChip">
|
||||||
|
{categoryLabel(categoryByName.get(s.name) ?? "other")}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{s.metrics.sharpeRatio.toFixed(2)}</td>
|
||||||
|
<td className={s.metrics.annualizedReturn >= 0 ? "positive" : "negative"}>
|
||||||
|
{pct(s.metrics.annualizedReturn)}
|
||||||
|
</td>
|
||||||
|
<td className={s.metrics.totalReturn >= 0 ? "positive" : "negative"}>
|
||||||
|
{pct(s.metrics.totalReturn)}
|
||||||
|
</td>
|
||||||
|
<td>{s.metrics.calmarRatio.toFixed(2)}</td>
|
||||||
|
<td>{s.metrics.sortinoRatio.toFixed(2)}</td>
|
||||||
|
<td className="negative">{pct(s.metrics.maxDrawdown)}</td>
|
||||||
|
<td>{pct(s.metrics.winRate)}</td>
|
||||||
|
<td>{pctOrDash(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());
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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">{money(result.meta.initialCapital)} 初始</span>
|
||||||
|
{scenario?.injectionSchedule && (
|
||||||
|
<>
|
||||||
|
<span className="equityMetaSep">·</span>
|
||||||
|
<span className="equityMetaItem">{summarizeScenario(scenario)}</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];
|
||||||
|
return (
|
||||||
|
<div className="panel metricsPanel">
|
||||||
|
<div className="panelHeader">
|
||||||
|
<h2>核心指标</h2>
|
||||||
|
{focused && <span>{displayStrategyName(focused)}</span>}
|
||||||
|
</div>
|
||||||
|
{focused ? (
|
||||||
|
<>
|
||||||
|
<div className="metricsGrid">
|
||||||
|
<MetricCell label="总收益" value={pct(focused.metrics.totalReturn)} positive={focused.metrics.totalReturn > 0} />
|
||||||
|
<MetricCell label="年化收益" value={pct(focused.metrics.annualizedReturn)} positive={focused.metrics.annualizedReturn > 0} />
|
||||||
|
<MetricCell label="年化波动" value={pct(focused.metrics.annualizedVolatility)} />
|
||||||
|
<MetricCell label="Sharpe" value={focused.metrics.sharpeRatio.toFixed(2)} positive={focused.metrics.sharpeRatio > 1} />
|
||||||
|
<MetricCell label="Sortino" value={focused.metrics.sortinoRatio.toFixed(2)} positive={focused.metrics.sortinoRatio > 1} />
|
||||||
|
<MetricCell label="最大回撤" value={pct(focused.metrics.maxDrawdown)} negative />
|
||||||
|
<MetricCell label="Calmar" value={focused.metrics.calmarRatio.toFixed(2)} positive={focused.metrics.calmarRatio > 0.5} />
|
||||||
|
<MetricCell label="日胜率" value={pct(focused.metrics.winRate)} positive={focused.metrics.winRate > 0.5} />
|
||||||
|
<MetricCell
|
||||||
|
label="再平衡胜率"
|
||||||
|
value={pctOrDash(focused.metrics.rebalanceWinRate)}
|
||||||
|
positive={
|
||||||
|
typeof focused.metrics.rebalanceWinRate === "number" &&
|
||||||
|
focused.metrics.rebalanceWinRate > 0.5
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="focusSummary">
|
||||||
|
<span><BarChart3 size={15} /> 终值 {money(focused.equityCurve.at(-1)?.value)}</span>
|
||||||
|
<span><TrendingDown size={15} /> 最大回撤 {pct(focused.metrics.maxDrawdown)}</span>
|
||||||
|
<span><TrendingUp size={15} /> 年化 {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>
|
||||||
|
<th>年化</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>
|
||||||
|
<span>{s.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className={s.metrics.annualizedReturn > 0 ? "positive" : "negative"}>{pct(s.metrics.annualizedReturn)}</td>
|
||||||
|
<td>{s.metrics.sharpeRatio.toFixed(2)}</td>
|
||||||
|
<td className="negative">{pct(s.metrics.maxDrawdown)}</td>
|
||||||
|
<td>{pct(s.metrics.winRate)}</td>
|
||||||
|
<td>{pctOrDash(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>收益热力图</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 = monthlyReturns[key];
|
||||||
|
return (
|
||||||
|
<td key={month} className="heatCell" style={{ background: heatColor(val) }}>
|
||||||
|
{val !== undefined ? `${(val * 100).toFixed(1)}` : ""}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className={`yearReturn ${(yearlyReturns[year] ?? 0) >= 0 ? "positive" : "negative"}`}>
|
||||||
|
{yearlyReturns[year] !== undefined ? 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);
|
||||||
|
};
|
||||||
|
return [...strategies].sort((a, b) => dir * (get(a) - get(b)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeScenario(sc: PrecomputedScenarioInfo): string {
|
||||||
|
if (!sc.injectionSchedule) return `${money(sc.capital)} 一次性`;
|
||||||
|
const inj = sc.injectionSchedule;
|
||||||
|
if (inj.frequency === "yearly_months" && inj.months?.length) {
|
||||||
|
const monthLabel = inj.months.join("/");
|
||||||
|
return `${money(sc.capital)} 起 · 每年 ${monthLabel} 月注入 ${money(inj.amount)}`;
|
||||||
|
}
|
||||||
|
const freqLabel = inj.frequency === "monthly" ? "每月" : inj.frequency === "quarterly" ? "每季" : "每年";
|
||||||
|
return `${money(sc.capital)} 起 · ${freqLabel}注入 ${money(inj.amount)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(val: number): string {
|
||||||
|
return `${(val * 100).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pctOrDash(val: number | null | undefined): string {
|
||||||
|
return typeof val === "number" && Number.isFinite(val) ? pct(val) : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: number | undefined): string {
|
||||||
|
return typeof value === "number" && Number.isFinite(value)
|
||||||
|
? value.toLocaleString(undefined, { maximumFractionDigits: 0, style: "currency", currency: "USD" })
|
||||||
|
: "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
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})`;
|
||||||
|
}
|
||||||
1204
src/client/main.tsx
1204
src/client/main.tsx
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,25 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { generateDailyReport } from "../agent/report";
|
import { generateDailyReport } from "../agent/report";
|
||||||
import { createProvider } from "../agent/provider";
|
import { createProvider } from "../agent/provider";
|
||||||
|
import { normalizeSymbol } from "../agent/symbols";
|
||||||
|
import { StockDataCache } from "../agent/stockDataCache";
|
||||||
import { WatchlistStore } from "../agent/watchlistStore";
|
import { WatchlistStore } from "../agent/watchlistStore";
|
||||||
import { loadIpadsConfig } from "./ipadsConfig";
|
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 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 dataDir = process.env.STOCK_AGENT_DATA_DIR ?? join(process.cwd(), "data");
|
||||||
|
|
||||||
const watchlistStore = new WatchlistStore(join(dataDir, "watchlist.json"));
|
const watchlistStore = new WatchlistStore(join(dataDir, "watchlist.json"));
|
||||||
|
const dataProvider = createProvider(loadIpadsConfig().apiKey);
|
||||||
|
const stockDataCache = new StockDataCache(join(dataDir, "cache"), dataProvider);
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
let cachedReportUpdatedAt = 0;
|
let cachedReportUpdatedAt = 0;
|
||||||
@@ -36,13 +45,23 @@ app.get("/api/watchlist", async (_request, response) => {
|
|||||||
|
|
||||||
app.post("/api/watchlist", async (request, response, next) => {
|
app.post("/api/watchlist", async (request, response, next) => {
|
||||||
try {
|
try {
|
||||||
response.status(201).json(await watchlistStore.add(String(request.body?.symbol ?? ""), request.body?.name));
|
response.status(201).json(await watchlistStore.add(String(request.body?.symbol ?? ""), request.body?.name, parseTags(request.body?.tags)));
|
||||||
cachedReport = undefined;
|
cachedReport = undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) => {
|
app.delete("/api/watchlist/:symbol", async (request, response, next) => {
|
||||||
try {
|
try {
|
||||||
await watchlistStore.remove(request.params.symbol);
|
await watchlistStore.remove(request.params.symbol);
|
||||||
@@ -53,6 +72,61 @@ app.delete("/api/watchlist/:symbol", async (request, response, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) => {
|
app.get("/api/report/daily", async (_request, response, next) => {
|
||||||
try {
|
try {
|
||||||
response.json(await getDailyReport());
|
response.json(await getDailyReport());
|
||||||
@@ -64,7 +138,148 @@ app.get("/api/report/daily", async (_request, response, next) => {
|
|||||||
app.post("/api/report/daily/refresh", async (_request, response, next) => {
|
app.post("/api/report/daily/refresh", async (_request, response, next) => {
|
||||||
try {
|
try {
|
||||||
cachedReport = undefined;
|
cachedReport = undefined;
|
||||||
response.json(await getDailyReport());
|
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;
|
||||||
|
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;
|
||||||
|
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,
|
||||||
|
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);
|
||||||
|
const scenarioId = requestedScenario && scenarios[requestedScenario]
|
||||||
|
? 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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -81,18 +296,51 @@ setInterval(() => {
|
|||||||
});
|
});
|
||||||
}, 24 * 60 * 60 * 1000).unref();
|
}, 24 * 60 * 60 * 1000).unref();
|
||||||
|
|
||||||
app.listen(port, "127.0.0.1", () => {
|
app.listen(port, host, () => {
|
||||||
console.log(`stock agent api listening at http://127.0.0.1:${port}`);
|
console.log(`stock agent api listening at http://${host}:${port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function getDailyReport() {
|
async function getDailyReport(forceSnapshots = false) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (cachedReport && now - cachedReportUpdatedAt < 24 * 60 * 60 * 1000) {
|
if (!forceSnapshots && cachedReport && now - cachedReportUpdatedAt < 24 * 60 * 60 * 1000) {
|
||||||
return cachedReport;
|
return cachedReport;
|
||||||
}
|
}
|
||||||
|
|
||||||
const watchlist = await watchlistStore.list();
|
const watchlist = await watchlistStore.list();
|
||||||
cachedReport = await generateDailyReport(watchlist, createProvider(loadIpadsConfig().apiKey), new Date());
|
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;
|
cachedReportUpdatedAt = now;
|
||||||
return cachedReport;
|
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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
|
host,
|
||||||
|
port: clientPort,
|
||||||
|
allowedHosts: ["gahow-pc.ipads-lab.se.sjtu.edu.cn"],
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": "http://127.0.0.1:8787"
|
"/api": `http://127.0.0.1:${apiPort}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user