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>
607 lines
19 KiB
TypeScript
607 lines
19 KiB
TypeScript
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)}%`;
|
||
}
|