Files
quant-trading/web/src/client/LivePage.tsx
Gahow Wang 5716267309 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>
2026-05-19 23:21:03 +08:00

607 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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