feat(web): 按 ui-ux-pro-max 设计系统重构前端(侧边导航 + 组件库 + 图标 + 可视化)
- 设计令牌与组件体系:语义色/间距/圆角/状态 + Card/Metric/Pill/Btn/Field/Empty/Skeleton/Progress 等 - 桌面左侧导航 + 窄屏抽屉(usePathname 高亮、ESC/遮罩关闭);修复抽屉/遮罩占据网格把 main 挤到第二行的问题 - 自绘内联 SVG 图标集替代 emoji;图标/表格/按钮等交互态齐全,空态/加载/错误/禁用态补齐 - 因子研究分层柱状、因子组合与回测净值/回撤双图 + 月度收益热力表、实验页任务状态徽标 - LineChart 主题化并按需引入 echarts(回测/组合首载 JS 448k→281k);next.config 放行局域网 dev 来源
This commit is contained in:
+152
-133
@@ -5,6 +5,19 @@ import { apiGet } from "@/lib/api";
|
||||
import { submitJob, waitJob } from "@/lib/jobs";
|
||||
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
|
||||
import { recentRange } from "@/lib/dates";
|
||||
import {
|
||||
PageHeader,
|
||||
Card,
|
||||
Pill,
|
||||
Field,
|
||||
Btn,
|
||||
Banner,
|
||||
Progress,
|
||||
Empty,
|
||||
BacktestMetrics,
|
||||
MonthlyReturnsTable,
|
||||
UnimplementedNote,
|
||||
} from "@/components/ui";
|
||||
import { LineChart } from "@/components/LineChart";
|
||||
|
||||
export default function BacktestPage() {
|
||||
@@ -21,15 +34,26 @@ export default function BacktestPage() {
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
apiGet<FactorMeta[]>("/factors")
|
||||
.then(setFactors)
|
||||
.catch((e: Error) => setError(e.message));
|
||||
.then((list) => {
|
||||
if (!alive) return;
|
||||
setFactors(list);
|
||||
if (list.length > 0 && !list.some((f) => f.name === factor)) {
|
||||
setFactor(list[0].name);
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => alive && setError(e.message));
|
||||
const { start: s, end: e } = recentRange();
|
||||
setStart(s);
|
||||
setEnd(e);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function run() {
|
||||
if (!factor) return;
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setJobId("");
|
||||
@@ -56,170 +80,165 @@ export default function BacktestPage() {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
setJobId("");
|
||||
}
|
||||
}
|
||||
|
||||
const s = result?.summary;
|
||||
return (
|
||||
<>
|
||||
<h1>选股回测</h1>
|
||||
<div className="card">
|
||||
<h2>研究参数(Research Specification)</h2>
|
||||
<PageHeader
|
||||
title="选股回测"
|
||||
sub="以单个因子的横截面排序构建 Top N 组合并完整回测:净值 / 回撤 / 月度收益 / 持仓明细。"
|
||||
actions={
|
||||
result ? (
|
||||
<Pill tone="pos" icon="check">
|
||||
最近一次回测已完成
|
||||
</Pill>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title="研究参数(Research Specification)" icon="gauge">
|
||||
<div className="form-grid">
|
||||
<label className="muted">
|
||||
因子
|
||||
<select value={factor} onChange={(e) => setFactor(e.target.value)}>
|
||||
<Field label="因子">
|
||||
<select
|
||||
className="input"
|
||||
value={factor}
|
||||
onChange={(e) => setFactor(e.target.value)}
|
||||
>
|
||||
{factors.map((f) => (
|
||||
<option key={f.name} value={f.name}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="muted">
|
||||
Top N
|
||||
</Field>
|
||||
<Field label="Top N">
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={50}
|
||||
value={topN}
|
||||
onChange={(e) => setTopN(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label className="muted">
|
||||
调仓
|
||||
</Field>
|
||||
<Field label="调仓频率">
|
||||
<select
|
||||
className="input"
|
||||
value={rebalance}
|
||||
onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")}
|
||||
>
|
||||
<option value="monthly">月度</option>
|
||||
<option value="weekly">周度</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="muted">
|
||||
开始
|
||||
<input type="date" value={start} onChange={(e) => setStart(e.target.value)} />
|
||||
</label>
|
||||
<label className="muted">
|
||||
结束
|
||||
<input type="date" value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||
</label>
|
||||
<label className="muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={excludeSt}
|
||||
onChange={(e) => setExcludeSt(e.target.checked)}
|
||||
/>{" "}
|
||||
剔除 ST
|
||||
</label>
|
||||
<button className="primary" onClick={run} disabled={running}>
|
||||
</Field>
|
||||
<Field label="开始日期">
|
||||
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="结束日期">
|
||||
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="标的范围">
|
||||
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
|
||||
剔除 ST
|
||||
</label>
|
||||
</Field>
|
||||
<Btn variant="primary" icon="play" loading={running} disabled={running} onClick={run}>
|
||||
{running ? "后台运行中…" : "运行回测"}
|
||||
</button>
|
||||
</Btn>
|
||||
</div>
|
||||
{running && jobId && <div className="muted">任务 {jobId} 后台执行中,请稍候…</div>}
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
|
||||
{result && s && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>
|
||||
结果 · {s.total_return_pct.toFixed(2)}%{" "}
|
||||
<span className={s.total_return_pct >= 0 ? "good" : "bad"}>
|
||||
{s.total_return_pct >= 0 ? "▲" : "▼"}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="stat-grid">
|
||||
<div className="stat">
|
||||
<div className="label">期末净值</div>
|
||||
<div className="value">{s.final_equity.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">年化收益</div>
|
||||
<div className="value">{s.annual_return_pct.toFixed(2)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">Sharpe</div>
|
||||
<div className="value">{s.sharpe.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">最大回撤</div>
|
||||
<div className="value bad">{s.max_drawdown_pct.toFixed(2)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">胜率(按交易)</div>
|
||||
<div className="value">{s.win_rate_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">交易笔数 / 换手</div>
|
||||
<div className="value">
|
||||
{s.total_trades} / {s.avg_turnover_pct.toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<LineChart title="净值曲线" data={result.equity_curve} color="#34d399" />
|
||||
</div>
|
||||
<LineChart
|
||||
title="回撤(%)"
|
||||
data={result.drawdown}
|
||||
color="#f87171"
|
||||
{running ? (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Progress
|
||||
value={35}
|
||||
label={
|
||||
<>
|
||||
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,请稍候…
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
|
||||
</Card>
|
||||
|
||||
<div className="card">
|
||||
<h2>月度收益(%)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>月份</th>
|
||||
<th>收益</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.monthly_returns.map((m) => (
|
||||
<tr key={`${m.year}-${m.month}`}>
|
||||
<td>
|
||||
{m.year}-{String(m.month).padStart(2, "0")}
|
||||
</td>
|
||||
<td className={m.return_pct >= 0 ? "good" : "bad"}>
|
||||
{m.return_pct.toFixed(2)}%
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!result && !running && !error ? (
|
||||
<Card>
|
||||
<Empty
|
||||
icon="gauge"
|
||||
title="尚未运行回测"
|
||||
hint="设置上方参数后运行;完成展示净值曲线、回撤、月度收益与最新持仓。"
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="card">
|
||||
<h2>最新持仓</h2>
|
||||
{(() => {
|
||||
const last = new Map<string, number>();
|
||||
for (const p of result.positions) last.set(p.date, 0);
|
||||
const lastDate = [...last.keys()].at(-1);
|
||||
const pos = result.positions.filter((p) => p.date === lastDate);
|
||||
return (
|
||||
<div className="row">
|
||||
{pos.map((p) => (
|
||||
<span key={p.symbol} className="stat">
|
||||
{p.symbol} · {(p.weight * 100).toFixed(0)}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>未建模约束(如实标注)</h2>
|
||||
<ul className="muted">
|
||||
{result.unimplemented.map((u, i) => (
|
||||
<li key={i}>{u}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{result ? <ResultView result={result} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultView({ result }: { result: BacktestResult }) {
|
||||
const s = result.summary;
|
||||
const last = result.positions.at(-1);
|
||||
const holdings = result.positions.filter((p) => p.date === last?.date);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="between" style={{ margin: "6px 0 14px" }}>
|
||||
<div className="row">
|
||||
<Pill tone="pos" icon="check">完成</Pill>
|
||||
<Pill tone="violet" icon="target">Top N 等权</Pill>
|
||||
<Pill>{s.start} ~ {s.end}</Pill>
|
||||
</div>
|
||||
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
|
||||
总收益 <SignedText value={s.total_return_pct} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BacktestMetrics s={s} />
|
||||
|
||||
<div className="chart-grid">
|
||||
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}>
|
||||
<LineChart data={result.equity_curve} color="#3ddc97" fill height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2)} />
|
||||
</Card>
|
||||
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
|
||||
<LineChart data={result.drawdown} color="#ff7a7a" height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card icon="calendar" title="月度收益(%)">
|
||||
<MonthlyReturnsTable rows={result.monthly_returns} />
|
||||
</Card>
|
||||
|
||||
<Card icon="target" title={`最新持仓 · ${last?.date ?? "—"}`}>
|
||||
{holdings.length === 0 ? (
|
||||
<Empty title="无持仓记录" hint="回测区间内没有产生持仓。" />
|
||||
) : (
|
||||
<div className="chips">
|
||||
{holdings.map((p) => (
|
||||
<span className="chip" key={p.symbol}>
|
||||
<b className="mono">{p.symbol}</b>
|
||||
{(p.weight * 100).toFixed(0)}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<UnimplementedNote items={result.unimplemented} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SignedText({ value }: { value: number }) {
|
||||
const cls = value >= 0 ? "tone-pos" : "tone-neg";
|
||||
return (
|
||||
<span className={cls}>
|
||||
{value > 0 ? "+" : ""}
|
||||
{value.toFixed(2)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { apiGet, apiPost } from "@/lib/api";
|
||||
import type { BacktestResult } from "@/lib/types";
|
||||
import {
|
||||
PageHeader,
|
||||
Card,
|
||||
Pill,
|
||||
Btn,
|
||||
Banner,
|
||||
Empty,
|
||||
Metric,
|
||||
SkeletonLines,
|
||||
} from "@/components/ui";
|
||||
import { Icon } from "@/components/icons";
|
||||
|
||||
interface ExperimentMeta {
|
||||
id: string;
|
||||
@@ -21,25 +32,76 @@ interface ExperimentDetail extends ExperimentMeta {
|
||||
result: BacktestResult | null;
|
||||
}
|
||||
|
||||
const KIND_MAP: Record<string, { label: string; tone: "accent" | "violet" | "default" }> = {
|
||||
backtest: { label: "回测", tone: "accent" },
|
||||
factor_test: { label: "因子测试", tone: "violet" },
|
||||
};
|
||||
|
||||
function kindView(kind: string) {
|
||||
const k = KIND_MAP[kind] ?? { label: kind, tone: "default" as const };
|
||||
return <Pill tone={k.tone}>{k.label}</Pill>;
|
||||
}
|
||||
|
||||
function statusView(status: string) {
|
||||
switch (status) {
|
||||
case "success":
|
||||
return (
|
||||
<Pill tone="pos" icon="check">
|
||||
完成
|
||||
</Pill>
|
||||
);
|
||||
case "running":
|
||||
return (
|
||||
<Pill tone="accent" icon="spinner">
|
||||
运行中
|
||||
</Pill>
|
||||
);
|
||||
case "queued":
|
||||
return (
|
||||
<Pill tone="warn" icon="clock">
|
||||
排队
|
||||
</Pill>
|
||||
);
|
||||
case "failed":
|
||||
return (
|
||||
<Pill tone="neg" icon="alert">
|
||||
失败
|
||||
</Pill>
|
||||
);
|
||||
case "cancelled":
|
||||
return <Pill tone="warn">已取消</Pill>;
|
||||
default:
|
||||
return <Pill>{status}</Pill>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ExperimentsPage() {
|
||||
const [exps, setExps] = useState<ExperimentMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detail, setDetail] = useState<ExperimentDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [jobId, setJobId] = useState("");
|
||||
const [jobStatus, setJobStatus] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function load() {
|
||||
const load = useCallback(() => {
|
||||
apiGet<ExperimentMeta[]>("/experiments")
|
||||
.then(setExps)
|
||||
.catch((e: Error) => setError(e.message));
|
||||
}
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(load, []);
|
||||
useEffect(load, [load]);
|
||||
|
||||
function open(id: string) {
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
apiGet<ExperimentDetail>(`/experiments/${id}`)
|
||||
.then(setDetail)
|
||||
.catch((e: Error) => setError(e.message));
|
||||
.then((d) => {
|
||||
setDetail(d);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setDetailLoading(false));
|
||||
}
|
||||
|
||||
function rerun(exp: ExperimentMeta) {
|
||||
@@ -65,94 +127,141 @@ export default function ExperimentsPage() {
|
||||
if (j.status === "success" || j.status === "failed" || tries > 60) {
|
||||
window.clearInterval(timer);
|
||||
load();
|
||||
setDetail(null);
|
||||
}
|
||||
})
|
||||
.catch(() => window.clearInterval(timer));
|
||||
}, 1000);
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
const s = detail?.result?.summary;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>实验归档(Experiment · 可复现)</h1>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{jobId && (
|
||||
<div className="card">
|
||||
复跑 Job <b>{jobId}</b> → 状态:<b>{jobStatus}</b>
|
||||
</div>
|
||||
)}
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>类型</th>
|
||||
<th>因子</th>
|
||||
<th>区间</th>
|
||||
<th>摘要</th>
|
||||
<th>代码版本</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{exps.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td>{e.id}</td>
|
||||
<td>{e.kind}</td>
|
||||
<td>{e.factors.join(", ")}</td>
|
||||
<td>
|
||||
{e.period ? `${e.period[0]} ~ ${e.period[1]}` : "—"}
|
||||
</td>
|
||||
<td>{e.summary_text ?? "—"}</td>
|
||||
<td>{e.code_version ?? "—"}</td>
|
||||
<td>
|
||||
<button onClick={() => open(e.id)}>详情</button>
|
||||
<button className="primary" onClick={() => rerun(e)}>
|
||||
复跑
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{exps.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="muted">
|
||||
暂无实验。先在「回测」或「因子研究」中运行一次,即会自动归档。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="实验归档"
|
||||
sub="每次因子测试与回测自动归档(含 Research Spec 与代码版本),保证可复现;支持一键复跑。"
|
||||
actions={exps.length > 0 ? <Pill tone="accent" icon="archive">共 {exps.length} 个实验</Pill> : undefined}
|
||||
/>
|
||||
|
||||
{detail && s && (
|
||||
<div className="card">
|
||||
<h2>
|
||||
{detail.id} · {detail.factors.join(", ")} · {detail.period?.[0]}~{detail.period?.[1]}
|
||||
</h2>
|
||||
<div className="stat-grid">
|
||||
<div className="stat">
|
||||
<div className="label">总收益</div>
|
||||
<div className="value">{s.total_return_pct.toFixed(2)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">年化</div>
|
||||
<div className="value">{s.annual_return_pct.toFixed(2)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">Sharpe</div>
|
||||
<div className="value">{s.sharpe.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">最大回撤</div>
|
||||
<div className="value bad">{s.max_drawdown_pct.toFixed(2)}%</div>
|
||||
</div>
|
||||
{error ? <Banner tone="error">{error}</Banner> : null}
|
||||
|
||||
{jobId ? (
|
||||
<Card
|
||||
icon="refresh"
|
||||
title={`复跑任务 ${jobId}`}
|
||||
tools={statusView(jobStatus === "submitting" ? "queued" : jobStatus)}
|
||||
>
|
||||
<div className="hint">
|
||||
任务状态实时轮询;完成后列表自动刷新,可直接展开查看最新结果。
|
||||
</div>
|
||||
<div className="muted" style={{ marginTop: 8 }}>
|
||||
spec(可复现输入):{JSON.stringify(detail.spec).slice(0, 400)}…
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="实验列表" icon="archive" flush>
|
||||
{loading ? (
|
||||
<div style={{ padding: 18 }}>
|
||||
<SkeletonLines n={6} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : exps.length === 0 ? (
|
||||
<Empty
|
||||
icon="archive"
|
||||
title="暂无实验"
|
||||
hint="在「回测」或「因子研究」中运行一次,即会自动归档于此。"
|
||||
/>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>类型</th>
|
||||
<th>因子</th>
|
||||
<th>区间</th>
|
||||
<th>摘要</th>
|
||||
<th>代码版本</th>
|
||||
<th style={{ width: 150, textAlign: "right" }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{exps.map((e) => (
|
||||
<tr key={e.id} className={detail?.id === e.id ? "row-active" : undefined}>
|
||||
<td className="cell-mono cell-strong">{e.id}</td>
|
||||
<td>{kindView(e.kind)}</td>
|
||||
<td>
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
{e.factors.map((f) => (
|
||||
<span key={f} className="tag tag--mono">
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</td>
|
||||
<td className="cell-mono">{e.period ? `${e.period[0]}~${e.period[1]}` : "—"}</td>
|
||||
<td className="hint">{e.summary_text ?? "—"}</td>
|
||||
<td>{e.code_version ? <span className="tag tag--mono">{e.code_version}</span> : <span className="hint">—</span>}</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<span className="row" style={{ justifyContent: "flex-end", gap: 6 }}>
|
||||
<Btn size="sm" icon="search" onClick={() => open(e.id)}>
|
||||
详情
|
||||
</Btn>
|
||||
<Btn size="sm" variant="primary" icon="refresh" onClick={() => rerun(e)} disabled={jobStatus === "running" || jobStatus === "queued" || jobStatus === "submitting"}>
|
||||
复跑
|
||||
</Btn>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{detail ? (
|
||||
<Card
|
||||
icon="book"
|
||||
title={`${detail.id} · ${detail.factors.join("、")}`}
|
||||
sub={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : undefined}
|
||||
tools={
|
||||
<Btn size="sm" onClick={() => setDetail(null)}>
|
||||
<Icon name="x" size={13} />
|
||||
关闭
|
||||
</Btn>
|
||||
}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<SkeletonLines n={3} />
|
||||
) : (
|
||||
<>
|
||||
{s ? (
|
||||
<div className="metric-grid" style={{ marginBottom: 0 }}>
|
||||
<Metric
|
||||
label="区间收益"
|
||||
tone={s.total_return_pct >= 0 ? "pos" : "neg"}
|
||||
value={`${s.total_return_pct >= 0 ? "+" : ""}${s.total_return_pct.toFixed(2)}%`}
|
||||
/>
|
||||
<Metric
|
||||
label="年化收益"
|
||||
tone={s.annual_return_pct >= 0 ? "pos" : "neg"}
|
||||
value={`${s.annual_return_pct >= 0 ? "+" : ""}${s.annual_return_pct.toFixed(2)}%`}
|
||||
/>
|
||||
<Metric label="Sharpe" value={s.sharpe.toFixed(2)} />
|
||||
<Metric label="最大回撤" tone="neg" value={`${s.max_drawdown_pct.toFixed(2)}%`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="hint">该实验没有可展示的汇总结果(可能是因子测试或历史版本)。</div>
|
||||
)}
|
||||
{detail.spec ? (
|
||||
<div className="hint" style={{ marginTop: 12 }}>
|
||||
spec(可复现输入):<span className="mono">{JSON.stringify(detail.spec).slice(0, 500)}</span>
|
||||
{JSON.stringify(detail.spec).length > 500 ? "…" : ""}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,24 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import { LineChart } from "@/components/LineChart";
|
||||
import { submitJob, waitJob } from "@/lib/jobs";
|
||||
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
|
||||
import { recentRange } from "@/lib/dates";
|
||||
import {
|
||||
PageHeader,
|
||||
Card,
|
||||
Pill,
|
||||
Field,
|
||||
Btn,
|
||||
Banner,
|
||||
Progress,
|
||||
Signed,
|
||||
BacktestMetrics,
|
||||
MonthlyReturnsTable,
|
||||
UnimplementedNote,
|
||||
} from "@/components/ui";
|
||||
import { LineChart } from "@/components/LineChart";
|
||||
import { Icon } from "@/components/icons";
|
||||
|
||||
interface Pick {
|
||||
name: string;
|
||||
@@ -26,19 +40,23 @@ export default function ComposePage() {
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
apiGet<FactorMeta[]>("/factors")
|
||||
.then((list) => {
|
||||
if (!alive) return;
|
||||
setFactors(list);
|
||||
// 默认示例组合:中期动量(重)+ 低波动(防)
|
||||
setPicks([
|
||||
{ name: "momentum_60", weight: 1.5 },
|
||||
{ name: "volatility_60", weight: 1.0 },
|
||||
]);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message));
|
||||
.catch((e: Error) => alive && setError(e.message));
|
||||
const { start: s, end: e } = recentRange();
|
||||
setStart(s);
|
||||
setEnd(e);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function togglePick(name: string) {
|
||||
@@ -53,6 +71,10 @@ export default function ComposePage() {
|
||||
setPicks((prev) => prev.map((p) => (p.name === name ? { ...p, weight } : p)));
|
||||
}
|
||||
|
||||
function removePick(name: string) {
|
||||
setPicks((prev) => prev.filter((p) => p.name !== name));
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const valid = picks.filter((p) => Number.isFinite(p.weight) && p.weight > 0);
|
||||
if (valid.length === 0) {
|
||||
@@ -90,171 +112,218 @@ export default function ComposePage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>因子组合</h1>
|
||||
<div className="card">
|
||||
<h2>组合方式(如何合成一个分数)</h2>
|
||||
<ol className="muted" style={{ margin: 0, paddingLeft: 18, lineHeight: 1.8 }}>
|
||||
<PageHeader
|
||||
title="因子组合"
|
||||
sub="多因子横截面 z-score 标准化后按权重叠加成综合分,每期取 Top N 等权持有。"
|
||||
actions={
|
||||
<Pill tone="accent" icon="layers">
|
||||
{picks.length} 个因子
|
||||
</Pill>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title="组合方式(如何合成一个分数)" icon="scale">
|
||||
<ol className="steplist">
|
||||
<li>选择若干因子并设置权重(默认 1;低为好方向由引擎自动反向)。</li>
|
||||
<li>
|
||||
每个交易日:每个因子在全体股票横截面上做 <b>z-score 标准化</b>
|
||||
(该日缺失因子值的股票不参与该因子得分)。
|
||||
每个交易日:每个因子在全体股票横截面上做 <b>z-score 标准化</b>(当日缺失因子值的股票不参与该因子得分)。
|
||||
</li>
|
||||
<li>
|
||||
综合得分 = <b>Σ(权重 × 标准化后因子得分)</b>,得分越高越优先。
|
||||
</li>
|
||||
<li>综合得分 = Σ(权重 × 标准化后因子得分),得分越高越优先。</li>
|
||||
<li>每期按综合得分取 Top N 等权持有,到下一调仓日再平衡(含成本 / 涨跌停 / 停牌近似)。</li>
|
||||
</ol>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="card">
|
||||
<h2>选因子 + 设权重</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>因子</th>
|
||||
<th>方向</th>
|
||||
<th>权重</th>
|
||||
<th>简介</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{factors.map((f) => {
|
||||
const pick = picks.find((p) => p.name === f.name);
|
||||
return (
|
||||
<tr key={f.name}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!pick}
|
||||
onChange={() => togglePick(f.name)}
|
||||
/>
|
||||
</td>
|
||||
<td>{f.name}</td>
|
||||
<td>{f.direction === "higher_is_better" ? "高为好" : "低为好"}</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
style={{ width: 80 }}
|
||||
value={pick?.weight ?? 1}
|
||||
disabled={!pick}
|
||||
onChange={(e) => setWeight(f.name, Number(e.target.value))}
|
||||
/>
|
||||
</td>
|
||||
<td className="muted">{f.brief ?? f.description}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>回测条件</h2>
|
||||
<div className="row">
|
||||
<label className="muted">
|
||||
Top N{" "}
|
||||
<input type="number" min={1} max={200} value={topN} onChange={(e) => setTopN(Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="muted">
|
||||
调仓{" "}
|
||||
<select value={rebalance} onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")}>
|
||||
<option value="monthly">月度</option>
|
||||
<option value="weekly">周度</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="muted">
|
||||
开始 <input type="date" value={start} onChange={(e) => setStart(e.target.value)} />
|
||||
</label>
|
||||
<label className="muted">
|
||||
结束 <input type="date" value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||
</label>
|
||||
<label className="muted">
|
||||
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
|
||||
{" "}剔除 ST
|
||||
</label>
|
||||
<button className="primary" onClick={run} disabled={running}>
|
||||
{running ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
|
||||
</button>
|
||||
</div>
|
||||
{running && jobId && <div className="muted">任务 {jobId} 后台执行中,请稍候…</div>}
|
||||
{error && <div className="error">{error}</div>}
|
||||
</div>
|
||||
|
||||
{result && <ResultView result={result} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultView({ result }: { result: BacktestResult }) {
|
||||
const s = result.summary;
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>
|
||||
结果 · {s.total_return_pct.toFixed(2)}%{" "}
|
||||
<span className={s.total_return_pct >= 0 ? "good" : "bad"}>
|
||||
{s.total_return_pct >= 0 ? "▲" : "▼"}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="stat-grid">
|
||||
<div className="stat">
|
||||
<div className="label">期末净值</div>
|
||||
<div className="value">{s.final_equity.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">年化收益</div>
|
||||
<div className="value">{s.annual_return_pct.toFixed(2)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">Sharpe</div>
|
||||
<div className="value">{s.sharpe.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">最大回撤</div>
|
||||
<div className="value bad">{s.max_drawdown_pct.toFixed(2)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">胜率</div>
|
||||
<div className="value">{s.win_rate_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">交易 / 换手</div>
|
||||
<div className="value">{s.total_trades} / {s.avg_turnover_pct.toFixed(0)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<LineChart title="净值曲线" data={result.equity_curve} color="#34d399" />
|
||||
</div>
|
||||
<LineChart title="回撤(%)" data={result.drawdown} color="#f87171" />
|
||||
</div>
|
||||
|
||||
{result.monthly_returns.length > 0 && (
|
||||
<div className="card">
|
||||
<h2>月度收益(%)</h2>
|
||||
<table>
|
||||
<Card
|
||||
title="选择因子并设置权重"
|
||||
icon="target"
|
||||
sub="权重可为小数(如 1.5 = 更侧重该因子)"
|
||||
flush
|
||||
>
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>月份</th>
|
||||
<th>收益</th>
|
||||
<th style={{ width: 46 }}></th>
|
||||
<th>因子</th>
|
||||
<th>方向</th>
|
||||
<th style={{ width: 120 }}>权重</th>
|
||||
<th>简介</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.monthly_returns.map((m) => (
|
||||
<tr key={`${m.year}-${m.month}`}>
|
||||
<td>{m.year}-{String(m.month).padStart(2, "0")}</td>
|
||||
<td className={m.return_pct >= 0 ? "good" : "bad"}>{m.return_pct.toFixed(2)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
{factors.map((f) => {
|
||||
const pick = picks.find((p) => p.name === f.name);
|
||||
return (
|
||||
<tr key={f.name} className={pick ? "row-active" : undefined}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!pick}
|
||||
onChange={() => togglePick(f.name)}
|
||||
aria-label={`选择因子 ${f.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td className="cell-strong">
|
||||
<span className="mono">{f.name}</span>
|
||||
</td>
|
||||
<td>
|
||||
<Pill tone={f.direction === "higher_is_better" ? "accent" : "violet"}>
|
||||
{f.direction === "higher_is_better" ? "高为好" : "低为好"}
|
||||
</Pill>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
style={{ width: 92 }}
|
||||
value={pick?.weight ?? 1}
|
||||
disabled={!pick}
|
||||
onChange={(e) => setWeight(f.name, Number(e.target.value))}
|
||||
aria-label={`${f.name} 权重`}
|
||||
/>
|
||||
</td>
|
||||
<td className="hint">{f.brief ?? f.description}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="card">
|
||||
<h2>未建模约束(如实标注)</h2>
|
||||
<ul className="muted">{result.unimplemented.map((u, i) => <li key={i}>{u}</li>)}</ul>
|
||||
<Card title="回测条件" icon="gauge">
|
||||
{picks.length > 0 ? (
|
||||
<div className="chips" style={{ marginBottom: 14 }}>
|
||||
{picks.map((p) => (
|
||||
<span className="chip" key={p.name}>
|
||||
<b className="mono">{p.name}</b>
|
||||
权重 {p.weight}
|
||||
<button
|
||||
type="button"
|
||||
className="chip__x"
|
||||
aria-label={`移除 ${p.name}`}
|
||||
onClick={() => removePick(p.name)}
|
||||
>
|
||||
<Icon name="x" size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="hint" style={{ marginBottom: 14 }}>
|
||||
尚未选择因子 —— 在上方勾选并设置权重。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-grid">
|
||||
<Field label="Top N">
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
min={1}
|
||||
max={200}
|
||||
value={topN}
|
||||
onChange={(e) => setTopN(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="调仓频率">
|
||||
<select
|
||||
className="input"
|
||||
value={rebalance}
|
||||
onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")}
|
||||
>
|
||||
<option value="monthly">月度</option>
|
||||
<option value="weekly">周度</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="开始日期">
|
||||
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="结束日期">
|
||||
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="标的范围">
|
||||
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
|
||||
剔除 ST
|
||||
</label>
|
||||
</Field>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="play"
|
||||
loading={running}
|
||||
disabled={running || picks.length === 0}
|
||||
onClick={run}
|
||||
>
|
||||
{running ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
{running ? (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Progress
|
||||
value={30}
|
||||
label={
|
||||
<>
|
||||
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,完成后结果自动展示在本页。
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
|
||||
</Card>
|
||||
|
||||
{result ? <ResultView result={result} params={{ picks, topN, rebalance, excludeSt, start, end }} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultView({
|
||||
result,
|
||||
params,
|
||||
}: {
|
||||
result: BacktestResult;
|
||||
params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string };
|
||||
}) {
|
||||
const s = result.summary;
|
||||
return (
|
||||
<>
|
||||
<div className="between" style={{ margin: "6px 0 14px" }}>
|
||||
<div className="row">
|
||||
<Pill tone="pos" icon="check">
|
||||
回测完成
|
||||
</Pill>
|
||||
<Pill tone="violet" icon="target">Top {params.topN}</Pill>
|
||||
<Pill>{params.rebalance === "monthly" ? "月度调仓" : "周度调仓"}</Pill>
|
||||
{params.excludeSt ? <Pill>剔除 ST</Pill> : null}
|
||||
<Pill>{params.start} ~ {params.end}</Pill>
|
||||
</div>
|
||||
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
|
||||
区间收益 <Signed value={s.total_return_pct} suffix="%" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BacktestMetrics s={s} />
|
||||
|
||||
<div className="chart-grid">
|
||||
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}>
|
||||
<LineChart data={result.equity_curve} color="#3ddc97" fill height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2)} />
|
||||
</Card>
|
||||
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
|
||||
<LineChart data={result.drawdown} color="#ff7a7a" height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card icon="calendar" title="月度收益(%)">
|
||||
<MonthlyReturnsTable rows={result.monthly_returns} />
|
||||
</Card>
|
||||
|
||||
<UnimplementedNote items={result.unimplemented} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+232
-139
@@ -5,32 +5,52 @@ import { apiGet } from "@/lib/api";
|
||||
import { submitJob, waitJob } from "@/lib/jobs";
|
||||
import type { FactorMeta, FactorTestReport, ResearchSpec } from "@/lib/types";
|
||||
import { recentRange } from "@/lib/dates";
|
||||
import {
|
||||
PageHeader,
|
||||
Card,
|
||||
Metric,
|
||||
Pill,
|
||||
Field,
|
||||
Btn,
|
||||
Banner,
|
||||
Progress,
|
||||
Empty,
|
||||
SkeletonLines,
|
||||
} from "@/components/ui";
|
||||
|
||||
export default function FactorsPage() {
|
||||
const [factors, setFactors] = useState<FactorMeta[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [start, setStart] = useState("");
|
||||
const [end, setEnd] = useState("");
|
||||
const [reports, setReports] = useState<Record<string, FactorTestReport>>({});
|
||||
const [running, setRunning] = useState(false);
|
||||
const [processed, setProcessed] = useState(0);
|
||||
const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null);
|
||||
const [jobId, setJobId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
apiGet<FactorMeta[]>("/factors")
|
||||
.then((list) => {
|
||||
if (!alive) return;
|
||||
setFactors(list);
|
||||
// 默认勾选两个代表性因子
|
||||
const picks = ["momentum_60", "volatility_60"].filter((n) => list.some((f) => f.name === n));
|
||||
const picks = ["momentum_60", "volatility_60"].filter((n) =>
|
||||
list.some((f) => f.name === n),
|
||||
);
|
||||
setChecked(new Set(picks));
|
||||
})
|
||||
.catch((e: Error) => setError(e.message));
|
||||
// 区间默认:近 6 个月(客户端设置,避免 SSR 日期不一致)
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => alive && setLoading(false));
|
||||
const { start: s, end: e } = recentRange();
|
||||
setStart(s);
|
||||
setEnd(e);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
function toggleCheck(name: string) {
|
||||
@@ -61,6 +81,7 @@ export default function FactorsPage() {
|
||||
setError("");
|
||||
setJobId("");
|
||||
setReports({});
|
||||
setProcessed(0);
|
||||
try {
|
||||
let index = 0;
|
||||
for (const name of names) {
|
||||
@@ -78,12 +99,12 @@ export default function FactorsPage() {
|
||||
setJobId(job_id);
|
||||
const out = await waitJob<FactorTestReport>(job_id);
|
||||
if (out.status === "success" && out.result) {
|
||||
const report = out.result;
|
||||
setReports((prev) => ({ ...prev, [name]: report }));
|
||||
setReports((prev) => ({ ...prev, [name]: out.result! }));
|
||||
} else {
|
||||
const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`;
|
||||
setError((prev) => (prev ? `${prev}\n${msg}` : msg));
|
||||
}
|
||||
setProcessed(index);
|
||||
}
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -95,87 +116,138 @@ export default function FactorsPage() {
|
||||
}
|
||||
|
||||
const selectedCount = checked.size;
|
||||
const doneCount = Object.keys(reports).length;
|
||||
const reportNames = Object.keys(reports);
|
||||
const progressPct = selectedCount === 0 ? 0 : Math.round((processed / selectedCount) * 100);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>因子研究</h1>
|
||||
<div className="card">
|
||||
<h2>因子目录(行情因子 · 无未来函数)</h2>
|
||||
<div className="muted" style={{ marginBottom: 8 }}>
|
||||
勾选因子后可多选;点击行展开详细说明。每个因子运行一次独立的 IC/RankIC 测试并归档。
|
||||
<a href="/factors/compose" style={{ marginLeft: 8, color: "#38bdf8" }}>
|
||||
用这些因子搭建选股组合 → 因子组合
|
||||
</a>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>因子</th>
|
||||
<th>回看</th>
|
||||
<th>方向</th>
|
||||
<th>简介(用法 / 何时有效)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{factors.map((f) => (
|
||||
<FactorRow
|
||||
key={f.name}
|
||||
factor={f}
|
||||
checked={checked.has(f.name)}
|
||||
expanded={expanded.has(f.name)}
|
||||
onToggleCheck={() => toggleCheck(f.name)}
|
||||
onToggleExpand={() => toggleExpand(f.name)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="muted" style={{ marginTop: 6 }}>
|
||||
计分规则:每因子在每日横截面上 z-score 标准化(低为好自动取负),组合页可按权重叠加得分选股;
|
||||
本页单个因子测试各自归档为 Experiment,可去「实验」页查看与复跑。
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="因子研究"
|
||||
sub="勾选行情因子并运行单因子测试(IC / RankIC / 分层)。每个因子独立异步执行并归档为实验,可在「实验」页查看与复跑。"
|
||||
actions={<Pill tone="accent" icon="flask">{reportNames.length}/{selectedCount} 个已出报告</Pill>}
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<h2>运行单因子测试(IC / RankIC / 分层)</h2>
|
||||
<div className="row">
|
||||
<label className="muted">
|
||||
开始 <input type="date" value={start} onChange={(e) => setStart(e.target.value)} />
|
||||
</label>
|
||||
<label className="muted">
|
||||
结束 <input type="date" value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||
</label>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={run}
|
||||
disabled={running || selectedCount === 0}
|
||||
>
|
||||
{running ? `运行中 ${doneCount}/${selectedCount}…` : `运行因子测试(已选 ${selectedCount} 个)`}
|
||||
</button>
|
||||
</div>
|
||||
{running && (
|
||||
<div className="muted">
|
||||
正在运行:{current?.name}({current?.idx}/{current?.total})—— 后台任务 {jobId},请稍候…
|
||||
{error && !running ? (
|
||||
<Banner tone="error">
|
||||
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
|
||||
</Banner>
|
||||
) : null}
|
||||
|
||||
<Card
|
||||
title="因子目录"
|
||||
icon="flask"
|
||||
sub="点击行展开定义 · 勾选后点下方「运行因子测试」"
|
||||
tools={<Pill tone="violet">已选 {selectedCount} 个</Pill>}
|
||||
flush
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ padding: 18 }}>
|
||||
<SkeletonLines n={7} />
|
||||
</div>
|
||||
) : factors.length === 0 ? (
|
||||
<Empty
|
||||
icon="flask"
|
||||
title="暂无因子目录"
|
||||
hint="请确认后端已启动,且 /api/factors 返回因子元数据。"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 44 }}></th>
|
||||
<th>因子</th>
|
||||
<th>回看</th>
|
||||
<th>方向</th>
|
||||
<th>简介(用法 / 何时有效)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{factors.map((f) => (
|
||||
<FactorRow
|
||||
key={f.name}
|
||||
factor={f}
|
||||
checked={checked.has(f.name)}
|
||||
expanded={expanded.has(f.name)}
|
||||
onToggleCheck={() => toggleCheck(f.name)}
|
||||
onToggleExpand={() => toggleExpand(f.name)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="hint" style={{ padding: "10px 16px", borderTop: "1px solid var(--line)" }}>
|
||||
计分规则:每个因子在每日横截面做 z-score 标准化(低为好自动取负);组合页按权重叠加得分选股。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && <div className="error" style={{ whiteSpace: "pre-line" }}>{error}</div>}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{Object.entries(reports).map(([name, report]) => {
|
||||
<Card title="运行单因子测试" icon="play" tools={running ? <Pill tone="accent" icon="spinner">执行中</Pill> : undefined}>
|
||||
<div className="form-grid">
|
||||
<Field label="开始日期">
|
||||
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="结束日期">
|
||||
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} />
|
||||
</Field>
|
||||
<Btn
|
||||
variant="primary"
|
||||
icon="play"
|
||||
loading={running}
|
||||
disabled={running || selectedCount === 0}
|
||||
onClick={run}
|
||||
>
|
||||
{running
|
||||
? `运行中 ${processed}/${selectedCount}`
|
||||
: selectedCount === 0
|
||||
? "请先勾选因子"
|
||||
: `运行因子测试(${selectedCount} 个)`}
|
||||
</Btn>
|
||||
</div>
|
||||
|
||||
{running ? (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<Progress
|
||||
value={progressPct}
|
||||
label={
|
||||
<>
|
||||
正在运行:<b>{current?.name}</b>({current?.idx}/{current?.total})· 后台任务{" "}
|
||||
<span className="mono">{jobId || "排队中…"}</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{reportNames.length === 0 && !running && !error ? (
|
||||
<Card>
|
||||
<Empty
|
||||
icon="chartLine"
|
||||
title="还没有测试报告"
|
||||
hint="勾选一个或多个因子并设置研究区间后运行;完成后报告会按因子逐一出现在这里。"
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{reportNames.map((name) => {
|
||||
const meta = factors.find((f) => f.name === name);
|
||||
return (
|
||||
<div className="card" key={name}>
|
||||
<h2>
|
||||
{name} · 测试报告
|
||||
{meta?.brief ? (
|
||||
<span className="muted" style={{ fontWeight: 400, fontSize: 13 }}>
|
||||
{" "}— {meta.brief}
|
||||
</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<ReportView report={report} />
|
||||
</div>
|
||||
<Card
|
||||
key={name}
|
||||
icon="chartLine"
|
||||
title={name}
|
||||
sub={meta?.brief}
|
||||
tools={
|
||||
<Pill tone="pos" icon="check">
|
||||
已完成
|
||||
</Pill>
|
||||
}
|
||||
>
|
||||
<ReportView report={reports[name]} />
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
@@ -193,81 +265,102 @@ function FactorRow(props: {
|
||||
return (
|
||||
<>
|
||||
<tr style={{ cursor: "pointer" }} onClick={props.onToggleExpand}>
|
||||
<td onClick={(e) => { e.stopPropagation(); props.onToggleCheck(); }}>
|
||||
<input type="checkbox" checked={props.checked} onChange={props.onToggleCheck} />
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.checked}
|
||||
onChange={props.onToggleCheck}
|
||||
aria-label={`选择因子 ${f.name}`}
|
||||
/>
|
||||
</td>
|
||||
<td>{f.name}</td>
|
||||
<td>{f.lookback}</td>
|
||||
<td>{f.direction === "higher_is_better" ? "高为好" : "低为好"}</td>
|
||||
<td>{f.brief ?? f.description}</td>
|
||||
<td className="cell-strong">
|
||||
<span className="mono">{f.name}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="tag tag--mono">{f.lookback} 日</span>
|
||||
</td>
|
||||
<td>
|
||||
<Pill tone={f.direction === "higher_is_better" ? "accent" : "violet"}>
|
||||
{f.direction === "higher_is_better" ? "高为好" : "低为好"}
|
||||
</Pill>
|
||||
</td>
|
||||
<td className="hint">{f.brief ?? f.description}</td>
|
||||
</tr>
|
||||
{props.expanded && (
|
||||
<tr>
|
||||
{props.expanded ? (
|
||||
<tr className="expand-cell">
|
||||
<td colSpan={5}>
|
||||
<div style={{ background: "var(--panel-2)", padding: 10, borderRadius: 6 }}>
|
||||
<b>{f.name}</b> · {f.description}
|
||||
<div className="muted" style={{ marginTop: 4 }}>
|
||||
公式:{f.formula};回看 {f.lookback} 个交易日;频率 {f.frequency};
|
||||
方向:{f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"}
|
||||
<div>
|
||||
<b className="mono">{f.name}</b> · {f.description}
|
||||
<div className="hint" style={{ marginTop: 6 }}>
|
||||
公式:<span className="mono">{f.formula}</span>;回看 {f.lookback} 个交易日;频率 {f.frequency};
|
||||
方向:
|
||||
{f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>{f.brief}</div>
|
||||
{f.brief ? <div className="hint" style={{ marginTop: 6 }}>{f.brief}</div> : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportView({ report }: { report: FactorTestReport }) {
|
||||
const qs = report.quantile_returns ?? [];
|
||||
const maxAbs = Math.max(0.001, ...qs.map((q) => Math.abs(q.return_pct)));
|
||||
return (
|
||||
<>
|
||||
<div className="stat-grid">
|
||||
<div className="stat">
|
||||
<div className="label">IC 均值</div>
|
||||
<div className="value">{report.ic_mean.toFixed(4)}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">RankIC 均值</div>
|
||||
<div className="value">{report.rank_ic_mean.toFixed(4)}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">ICIR</div>
|
||||
<div className="value">{report.icir.toFixed(3)}</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">正收益占比</div>
|
||||
<div className="value">{report.positive_ratio_pct.toFixed(1)}%</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">样本日数</div>
|
||||
<div className="value">{report.sample_days}</div>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<div className="metric-grid" style={{ marginBottom: 0 }}>
|
||||
<Metric
|
||||
label="IC 均值"
|
||||
icon="chartLine"
|
||||
value={report.ic_mean.toFixed(4)}
|
||||
tone={report.ic_mean >= 0 ? "pos" : "neg"}
|
||||
/>
|
||||
<Metric
|
||||
label="RankIC 均值"
|
||||
value={report.rank_ic_mean.toFixed(4)}
|
||||
tone={report.rank_ic_mean >= 0 ? "pos" : "neg"}
|
||||
/>
|
||||
<Metric
|
||||
label="ICIR"
|
||||
value={report.icir.toFixed(3)}
|
||||
tone={report.icir >= 1 ? "pos" : "plain"}
|
||||
sub="越大越稳定"
|
||||
/>
|
||||
<Metric
|
||||
label="正收益占比"
|
||||
value={`${report.positive_ratio_pct.toFixed(1)}%`}
|
||||
tone={report.positive_ratio_pct >= 50 ? "pos" : "warn"}
|
||||
/>
|
||||
<Metric label="样本日数" value={report.sample_days} icon="calendar" />
|
||||
</div>
|
||||
{report.quantile_returns.length > 0 && (
|
||||
<table style={{ marginTop: 12 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>分层(1 最低 → 5 最高)</th>
|
||||
{report.quantile_returns.map((q) => (
|
||||
<th key={q.quantile}>Q{q.quantile + 1}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>未来 21 日平均收益</td>
|
||||
{report.quantile_returns.map((q) => (
|
||||
<td key={q.quantile}>{q.return_pct.toFixed(2)}%</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<div className="muted" style={{ marginTop: 6 }}>
|
||||
读数:IC/RankIC 为正表示与未来收益正相关;ICIR 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。
|
||||
单因子测试≠策略有效,需结合样本外与稳健性分析。
|
||||
|
||||
{qs.length > 0 ? (
|
||||
<div>
|
||||
<div className="field__label" style={{ marginBottom: 2 }}>
|
||||
分层表现(Q1 最低因子值 → Q5 最高,未来 21 日平均收益)
|
||||
</div>
|
||||
<div className="vbars" role="img" aria-label="分层收益柱状图">
|
||||
{qs.map((q) => {
|
||||
const v = q.return_pct;
|
||||
const h = Math.max(4, Math.round((Math.abs(v) / maxAbs) * 72));
|
||||
return (
|
||||
<div className="vbars__item" key={q.quantile}>
|
||||
<span className="vbars__val">{v >= 0 ? "+" : ""}{v.toFixed(2)}%</span>
|
||||
<div className={`vbars__bar ${v >= 0 ? "is-pos" : "is-neg"}`} style={{ height: h }} />
|
||||
<span className="vbars__cap">Q{q.quantile + 1}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="hint">
|
||||
读数:IC / RankIC 为正表示与未来收益正相关,ICIR 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。
|
||||
单因子测试 ≠ 策略有效,需结合样本外与稳健性分析。
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1227
-103
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "qlib-platform · A股量化研究",
|
||||
description: "个人 A 股量化研究平台:股票池 / 因子 / 回测",
|
||||
description: "个人 A 股量化研究平台:股票池 / 因子研究 / 因子组合 / 回测 / 实验归档",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#060912",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>
|
||||
<nav>
|
||||
<span className="brand">qlib-platform</span>
|
||||
<Link href="/">总览</Link>
|
||||
<Link href="/stocks">股票池</Link>
|
||||
<Link href="/factors">因子研究</Link>
|
||||
<Link href="/factors/compose">因子组合</Link>
|
||||
<Link href="/backtest">回测</Link>
|
||||
<Link href="/experiments">实验</Link>
|
||||
</nav>
|
||||
<main>{children}</main>
|
||||
<AppShell>{children}</AppShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+127
-28
@@ -1,49 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import type { Stock, FactorMeta } from "@/lib/types";
|
||||
import { PageHeader, Card, Metric, Banner, Loading } from "@/components/ui";
|
||||
import { Icon as Ic } from "@/components/icons";
|
||||
|
||||
interface LoadState {
|
||||
stocks: number | null;
|
||||
factors: number | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
href: "/factors",
|
||||
title: "因子研究",
|
||||
desc: "查看行情因子目录,勾选运行 IC / RankIC 单因子测试",
|
||||
icon: "flask" as const,
|
||||
tone: "quick-card--violet",
|
||||
},
|
||||
{
|
||||
href: "/factors/compose",
|
||||
title: "因子组合",
|
||||
desc: "多因子 z-score 叠加打分,回测 Top N 等权选股组合",
|
||||
icon: "layers" as const,
|
||||
tone: "quick-card--pos",
|
||||
},
|
||||
{
|
||||
href: "/backtest",
|
||||
title: "选股回测",
|
||||
desc: "单因子完整回测:净值 / 回撤 / 月度收益 / 持仓",
|
||||
icon: "gauge" as const,
|
||||
tone: "quick-card--warn",
|
||||
},
|
||||
{
|
||||
href: "/stocks",
|
||||
title: "股票池",
|
||||
desc: "浏览本地股票基础信息(Tushare 同步,全市场 5556 只)",
|
||||
icon: "candles" as const,
|
||||
tone: "",
|
||||
},
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stockCount, setStockCount] = useState<number | null>(null);
|
||||
const [factorCount, setFactorCount] = useState<number | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [st, setSt] = useState<LoadState>({
|
||||
stocks: null,
|
||||
factors: null,
|
||||
loading: true,
|
||||
error: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
apiGet<Stock[]>("/stocks?limit=500"),
|
||||
apiGet<FactorMeta[]>("/factors"),
|
||||
])
|
||||
let alive = true;
|
||||
Promise.all([apiGet<Stock[]>("/stocks?limit=500"), apiGet<FactorMeta[]>("/factors")])
|
||||
.then(([stocks, factors]) => {
|
||||
setStockCount(stocks.length);
|
||||
setFactorCount(factors.length);
|
||||
if (!alive) return;
|
||||
setSt({
|
||||
stocks: stocks.length,
|
||||
factors: factors.length,
|
||||
loading: false,
|
||||
error: "",
|
||||
});
|
||||
})
|
||||
.catch((e: Error) => setError(e.message));
|
||||
.catch((e: Error) => {
|
||||
if (!alive) return;
|
||||
setSt({ stocks: null, factors: null, loading: false, error: e.message });
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>总览</h1>
|
||||
{error && <div className="error">后端不可达:{error}</div>}
|
||||
<div className="stat-grid">
|
||||
<div className="stat">
|
||||
<div className="label">股票池</div>
|
||||
<div className="value">{stockCount ?? "—"}</div>
|
||||
<PageHeader
|
||||
title="研究总览"
|
||||
sub="个人 A 股量化研究平台:以 Tushare 行情为数据底座、Qlib 为研究引擎,覆盖选股、因子研究、组合回测与实验归档。"
|
||||
/>
|
||||
|
||||
{st.error ? (
|
||||
<Banner tone="error">
|
||||
后端不可达:{st.error}。请确认 FastAPI 服务已启动(scripts/dev.sh start)。
|
||||
</Banner>
|
||||
) : null}
|
||||
|
||||
{st.loading ? (
|
||||
<div className="metric-grid">
|
||||
<div className="metric">
|
||||
<Loading label="正在读取股票池…" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="label">内置因子</div>
|
||||
<div className="value">{factorCount ?? "—"}</div>
|
||||
) : (
|
||||
<div className="metric-grid">
|
||||
<Metric
|
||||
label="股票池"
|
||||
icon="candles"
|
||||
value={st.stocks?.toLocaleString() ?? "—"}
|
||||
sub="已建档基础信息"
|
||||
/>
|
||||
<Metric
|
||||
label="内置因子"
|
||||
icon="flask"
|
||||
value={st.factors?.toLocaleString() ?? "—"}
|
||||
sub="行情因子 · 无未来函数"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<h2>说明</h2>
|
||||
<div className="muted">
|
||||
个人 A 股量化研究平台(Phase 3 Web)。
|
||||
顶部导航进入「股票池 / 因子研究 / 回测」;研究基于本地 SQLite 行情
|
||||
(Tushare 同步,20 只权重股 2023–2024 日线),因子测试与回测均由后端
|
||||
Research Specification 驱动并返回标准化结果。
|
||||
)}
|
||||
|
||||
<Card title="快速开始" icon="spark" sub="四个研究入口,从数据到结论" flush={false}>
|
||||
<div className="quick-cards" style={{ marginBottom: 0 }}>
|
||||
{LINKS.map((l) => (
|
||||
<Link key={l.href} href={l.href} className={`quick-card ${l.tone}`}>
|
||||
<span className="quick-ic">
|
||||
<Ic name={l.icon} size={19} />
|
||||
</span>
|
||||
<span>
|
||||
<h3>{l.title}</h3>
|
||||
<p>{l.desc}</p>
|
||||
<span className="go">
|
||||
进入 <Ic name="arrowUpRight" size={13} />
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="研究须知" icon="book">
|
||||
<ul className="steplist">
|
||||
<li>
|
||||
因子/回测请求经 <b>异步 Job</b> 后台执行并自动归档为实验(Experiment),可在「实验」页查看与复跑。
|
||||
</li>
|
||||
<li>
|
||||
数据底座为<b>本地 SQLite + Parquet</b>:行情经 <code>sync daily --resume</code> 增量同步;
|
||||
新浪数据仅在「两边一致」校验通过后兜底入库(<code>source=sina</code>)。
|
||||
</li>
|
||||
<li>
|
||||
研究默认<b>禁止未来函数</b>:财务按公告日可见、因子按已披露数据计算;回测含手续费 / 印花税 / 涨跌停等近似约束(如实标注)。
|
||||
</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,58 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { apiGet } from "@/lib/api";
|
||||
import type { Stock } from "@/lib/types";
|
||||
import {
|
||||
PageHeader,
|
||||
Card,
|
||||
Banner,
|
||||
Empty,
|
||||
Pill,
|
||||
SkeletonLines,
|
||||
SearchBox,
|
||||
} from "@/components/ui";
|
||||
import { Icon } from "@/components/icons";
|
||||
|
||||
const LIMIT = 300;
|
||||
|
||||
function marketOf(symbol: string): string {
|
||||
return symbol.slice(-2);
|
||||
}
|
||||
|
||||
function statusView(s: Stock) {
|
||||
switch (s.status) {
|
||||
case "L":
|
||||
return (
|
||||
<Pill tone="pos">
|
||||
上市
|
||||
</Pill>
|
||||
);
|
||||
case "D":
|
||||
return <Pill tone="neg">退市</Pill>;
|
||||
case "P":
|
||||
return <Pill tone="warn">暂停</Pill>;
|
||||
default:
|
||||
return <Pill>{s.status}</Pill>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function StocksPage() {
|
||||
const [stocks, setStocks] = useState<Stock[]>([]);
|
||||
const [q, setQ] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const first = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
apiGet<Stock[]>(`/stocks?limit=300&q=${encodeURIComponent(q)}`)
|
||||
.then(setStocks)
|
||||
.catch((e: Error) => setError(e.message));
|
||||
const load = useCallback(async () => {
|
||||
setError("");
|
||||
try {
|
||||
const list = await apiGet<Stock[]>(
|
||||
`/stocks?limit=${LIMIT}&q=${encodeURIComponent(q)}`,
|
||||
);
|
||||
setStocks(list);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setStocks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [q]);
|
||||
|
||||
// 输入防抖 250ms 后再请求
|
||||
useEffect(() => {
|
||||
if (first.current) {
|
||||
first.current = false;
|
||||
void load();
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const t = window.setTimeout(() => void load(), 250);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [q, reloadKey, load]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>股票池</h1>
|
||||
<div className="row" style={{ marginBottom: 12 }}>
|
||||
<input
|
||||
placeholder="按代码 / 名称搜索(如 600519 或 茅台)"
|
||||
<PageHeader
|
||||
title="股票池"
|
||||
sub="本地股票基础信息(Tushare 同步)。支持按代码 / 名称模糊搜索,最多展示 300 条。"
|
||||
actions={
|
||||
<>
|
||||
<Pill tone="accent" icon="candles">
|
||||
共 {stocks.length} 条
|
||||
</Pill>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
aria-label="刷新列表"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
setReloadKey((k) => k + 1);
|
||||
}}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
刷新
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Banner tone="error">加载失败:{error}</Banner>
|
||||
) : null}
|
||||
|
||||
<div className="row" style={{ marginBottom: 14 }}>
|
||||
<SearchBox
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onChange={setQ}
|
||||
onClear={() => setQ("")}
|
||||
placeholder="按代码 / 名称搜索,如 600519 或 茅台"
|
||||
label="搜索股票"
|
||||
/>
|
||||
<span className="muted">共 {stocks.length} 条</span>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>代码</th>
|
||||
<th>名称</th>
|
||||
<th>行业</th>
|
||||
<th>地区</th>
|
||||
<th>上市日</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stocks.map((s) => (
|
||||
<tr key={s.symbol}>
|
||||
<td>{s.symbol}</td>
|
||||
<td>{s.name}</td>
|
||||
<td>{s.industry ?? "—"}</td>
|
||||
<td>{s.area ?? "—"}</td>
|
||||
<td>{s.list_date}</td>
|
||||
<td>{s.status}</td>
|
||||
|
||||
<Card flush>
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>代码</th>
|
||||
<th>名称</th>
|
||||
<th>市场</th>
|
||||
<th>行业</th>
|
||||
<th>地区</th>
|
||||
<th>上市日</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stocks.map((s) => (
|
||||
<tr key={s.symbol}>
|
||||
<td className="cell-mono cell-strong">{s.symbol}</td>
|
||||
<td>{s.name}</td>
|
||||
<td>
|
||||
<span className="tag tag--mono">{marketOf(s.symbol)}</span>
|
||||
</td>
|
||||
<td>{s.industry ? <span className="tag">{s.industry}</span> : <span className="hint">—</span>}</td>
|
||||
<td className="hint">{s.area ?? "—"}</td>
|
||||
<td className="cell-mono">{s.list_date}</td>
|
||||
<td>{statusView(s)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{loading && !error && stocks.length === 0 ? (
|
||||
<div style={{ padding: 18 }}>
|
||||
<SkeletonLines n={6} />
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !error && stocks.length === 0 ? (
|
||||
<Empty
|
||||
icon="search"
|
||||
title={q ? "没有匹配的股票" : "股票池为空"}
|
||||
hint={
|
||||
q
|
||||
? `「${q}」没有命中。试试更短的代码或名称关键词。`
|
||||
: "请先通过后端同步基础信息:uv run python -m app.cli.sync basic"
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{stocks.length > 0 ? (
|
||||
<div
|
||||
className="hint"
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderTop: "1px solid var(--line)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<span>显示前 {stocks.length} 条(接口上限 {LIMIT})</span>
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
<Dot color="var(--pos)" />
|
||||
上市
|
||||
</span>
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
<Dot color="var(--warn)" />
|
||||
暂停
|
||||
</span>
|
||||
<span className="row" style={{ gap: 4 }}>
|
||||
<Dot color="var(--neg)" />
|
||||
退市
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ color }: { color: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,106 @@
|
||||
"use client";
|
||||
|
||||
/** 基于 ECharts 的折线图(净值 / 回撤),按需引入模块以压缩 bundle。 */
|
||||
import { useEffect, useRef } from "react";
|
||||
import * as echarts from "echarts";
|
||||
import * as echarts from "echarts/core";
|
||||
import { LineChart as EChartsLine } from "echarts/charts";
|
||||
import { GridComponent, TitleComponent, TooltipComponent } from "echarts/components";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
|
||||
echarts.use([EChartsLine, GridComponent, TitleComponent, TooltipComponent, CanvasRenderer]);
|
||||
|
||||
export interface XY {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const C = {
|
||||
text: "#a8b3c9",
|
||||
faint: "#8b98b2",
|
||||
line: "rgba(150,165,195,0.13)",
|
||||
tooltipBg: "#141d30",
|
||||
tooltipBorder: "rgba(150,165,195,0.28)",
|
||||
};
|
||||
|
||||
export function LineChart({
|
||||
title,
|
||||
data,
|
||||
color = "#38bdf8",
|
||||
color = "#3fb6ff",
|
||||
fill = false,
|
||||
height = 320,
|
||||
yFmt,
|
||||
xFmt,
|
||||
}: {
|
||||
title: string;
|
||||
title?: string;
|
||||
data: XY[];
|
||||
color?: string;
|
||||
/** 线下渐变面积(净值类曲线推荐开启) */
|
||||
fill?: boolean;
|
||||
height?: number;
|
||||
yFmt?: (v: number) => string;
|
||||
xFmt?: (v: string) => string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || data.length === 0) return;
|
||||
const chart = echarts.init(ref.current);
|
||||
const areaColor = {
|
||||
type: "linear",
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: color + "3d" },
|
||||
{ offset: 1, color: color + "00" },
|
||||
],
|
||||
};
|
||||
chart.setOption({
|
||||
title: { text: title, textStyle: { color: "#e2e8f0", fontSize: 13 } },
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: { left: 60, right: 20, top: 40, bottom: 30 },
|
||||
backgroundColor: "transparent",
|
||||
title: title
|
||||
? {
|
||||
text: title,
|
||||
textStyle: { color: "#e9eef7", fontSize: 13, fontWeight: 600 },
|
||||
}
|
||||
: undefined,
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
backgroundColor: C.tooltipBg,
|
||||
borderColor: C.tooltipBorder,
|
||||
textStyle: { color: "#e9eef7", fontSize: 12 },
|
||||
valueFormatter: (v: unknown) =>
|
||||
typeof v === "number" ? (yFmt ? yFmt(v) : String(v)) : String(v),
|
||||
},
|
||||
grid: { left: 8, right: 14, top: title ? 42 : 18, bottom: 8, containLabel: true },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
boundaryGap: false,
|
||||
data: data.map((p) => p.date),
|
||||
axisLabel: { color: "#94a3b8", fontSize: 10 },
|
||||
axisLine: { lineStyle: { color: C.line } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: C.faint,
|
||||
fontSize: 10,
|
||||
formatter: (v: string) => (xFmt ? xFmt(v) : v),
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
scale: true,
|
||||
axisLabel: { color: "#94a3b8" },
|
||||
splitLine: { lineStyle: { color: "#334155" } },
|
||||
axisLabel: { color: C.faint, fontSize: 10, formatter: yFmt },
|
||||
splitLine: { lineStyle: { color: C.line } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
showSymbol: false,
|
||||
symbol: "circle",
|
||||
data: data.map((p) => p.value),
|
||||
lineStyle: { color, width: 1.6 },
|
||||
lineStyle: { color, width: 1.8 },
|
||||
itemStyle: { color },
|
||||
areaStyle: fill ? { color: areaColor } : undefined,
|
||||
emphasis: { focus: "series" },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -53,8 +110,8 @@ export function LineChart({
|
||||
window.removeEventListener("resize", onResize);
|
||||
chart.dispose();
|
||||
};
|
||||
}, [data, color, title]);
|
||||
}, [data, color, fill, title, height, yFmt, xFmt]);
|
||||
|
||||
if (data.length === 0) return <div className="muted">暂无数据</div>;
|
||||
return <div ref={ref} style={{ height: 360, width: "100%" }} />;
|
||||
if (data.length === 0) return <div className="hint">暂无数据</div>;
|
||||
return <div ref={ref} style={{ height, width: "100%" }} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
/** 应用外壳:桌面左侧导航栏 + 窄屏顶栏与抽屉(usePathname 高亮当前页)。 */
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Icon, type IconName } from "@/components/icons";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
}
|
||||
|
||||
const GROUPS: { title: string; items: NavItem[] }[] = [
|
||||
{
|
||||
title: "研究",
|
||||
items: [
|
||||
{ href: "/", label: "总览", icon: "grid" },
|
||||
{ href: "/stocks", label: "股票池", icon: "candles" },
|
||||
{ href: "/factors", label: "因子研究", icon: "flask" },
|
||||
{ href: "/factors/compose", label: "因子组合", icon: "layers" },
|
||||
{ href: "/backtest", label: "选股回测", icon: "gauge" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "归档",
|
||||
items: [{ href: "/experiments", label: "实验", icon: "archive" }],
|
||||
},
|
||||
];
|
||||
|
||||
function isActive(href: string, pathname: string): boolean {
|
||||
if (href === "/") return pathname === "/";
|
||||
if (href === "/factors") return pathname === "/factors";
|
||||
if (pathname === href || pathname.startsWith(href + "/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function Brand() {
|
||||
return (
|
||||
<Link href="/" className="brand" style={{ textDecoration: "none" }}>
|
||||
<span className="logo">
|
||||
<Icon name="logo" size={18} />
|
||||
</span>
|
||||
<span>
|
||||
qlib-platform
|
||||
<small>个人 A 股量化研究台</small>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLinks({ pathname, onNavigate }: { pathname: string; onNavigate?: () => void }) {
|
||||
return (
|
||||
<>
|
||||
{GROUPS.map((g) => (
|
||||
<div key={g.title}>
|
||||
<div className="nav-label">{g.title}</div>
|
||||
{g.items.map((item) => {
|
||||
const active = isActive(item.href, pathname);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={active ? "nav-link active" : "nav-link"}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<span className="nav-ic">
|
||||
<Icon name={item.icon} size={17} />
|
||||
</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// 路由变化时收起抽屉
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="rail nav" aria-label="主导航">
|
||||
<Brand />
|
||||
<NavLinks pathname={pathname} />
|
||||
<div className="rail-foot">
|
||||
数据源 Tushare 首选 · 新浪校验兜底
|
||||
<br />
|
||||
研究引擎 Qlib(本地 SQLite / Parquet)
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="topbar">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
aria-label="打开导航菜单"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
style={{ width: 36, height: 36 }}
|
||||
>
|
||||
<Icon name="filter" size={18} />
|
||||
</button>
|
||||
<Brand />
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="scrim show" aria-hidden="true" onClick={() => setOpen(false)} />
|
||||
) : null}
|
||||
<aside
|
||||
className={open ? "drawer nav open" : "drawer nav"}
|
||||
aria-label="移动端导航"
|
||||
aria-hidden={!open}
|
||||
>
|
||||
{open ? <NavLinks pathname={pathname} onNavigate={() => setOpen(false)} /> : null}
|
||||
</aside>
|
||||
|
||||
<main className="content">
|
||||
<div className="page">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 内联 SVG 图标集(Phosphor 风格线性图标,1.6px 描边,stroke=currentColor)。
|
||||
* 不引入第三方图标库(离线可用),禁止用 emoji 作结构图标。
|
||||
*
|
||||
* 用法:<Icon name="search" size={16} /> 或 <Icon.Home .../>
|
||||
*/
|
||||
import type { ReactNode, SVGProps } from "react";
|
||||
|
||||
export type IconName =
|
||||
| "logo"
|
||||
| "grid"
|
||||
| "candles"
|
||||
| "flask"
|
||||
| "layers"
|
||||
| "gauge"
|
||||
| "archive"
|
||||
| "search"
|
||||
| "x"
|
||||
| "chevDown"
|
||||
| "chevRight"
|
||||
| "arrowUpRight"
|
||||
| "arrowUp"
|
||||
| "arrowDown"
|
||||
| "play"
|
||||
| "refresh"
|
||||
| "spinner"
|
||||
| "alert"
|
||||
| "check"
|
||||
| "info"
|
||||
| "database"
|
||||
| "calendar"
|
||||
| "target"
|
||||
| "chartLine"
|
||||
| "scale"
|
||||
| "book"
|
||||
| "link"
|
||||
| "spark"
|
||||
| "clock"
|
||||
| "filter";
|
||||
|
||||
const P: Record<
|
||||
IconName,
|
||||
(p: { filled?: boolean }) => ReactNode
|
||||
> = {
|
||||
logo: () => (
|
||||
<>
|
||||
<path d="M4 20V4h4v6h4V4h4v10h4V4h4v16H4z" fill="currentColor" stroke="none" />
|
||||
<path d="M3 20h18" strokeWidth="2.4" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
grid: () => (
|
||||
<>
|
||||
<rect x="3.5" y="3.5" width="7" height="7" rx="1.6" />
|
||||
<rect x="13.5" y="3.5" width="7" height="7" rx="1.6" />
|
||||
<rect x="3.5" y="13.5" width="7" height="7" rx="1.6" />
|
||||
<rect x="13.5" y="13.5" width="7" height="7" rx="1.6" />
|
||||
</>
|
||||
),
|
||||
candles: () => (
|
||||
<>
|
||||
<path d="M7 3v3.2M7 17.8V21M7 6.2a1.8 1.8 0 0 1 1.8 1.8v6a1.8 1.8 0 0 1-1.8 1.8v0a1.8 1.8 0 0 1-1.8-1.8V8a1.8 1.8 0 0 1 1.8-1.8z" />
|
||||
<path d="M17 3v2M17 19v2M17 5a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2v0a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z" />
|
||||
</>
|
||||
),
|
||||
flask: () => (
|
||||
<>
|
||||
<path d="M9.5 3h5M10.5 3v6.2L5.2 18.6A2 2 0 0 0 6.9 21.5h10.2a2 2 0 0 0 1.7-2.9L13.5 9.2V3" />
|
||||
<path d="M7.2 15.5h9.6" />
|
||||
</>
|
||||
),
|
||||
layers: () => (
|
||||
<>
|
||||
<path d="M12 3.5 21 8.5l-9 5-9-5 9-5z" />
|
||||
<path d="m4.5 12.5 7.5 4.2 7.5-4.2M4.5 16.5l7.5 4.2 7.5-4.2" />
|
||||
</>
|
||||
),
|
||||
gauge: () => (
|
||||
<>
|
||||
<path d="M12 21a9 9 0 1 1 9-9" />
|
||||
<path d="M12 12.5 16.6 6.8" />
|
||||
<circle cx="12" cy="12.5" r="1.5" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
archive: () => (
|
||||
<>
|
||||
<rect x="3.5" y="4" width="17" height="5" rx="1.6" />
|
||||
<path d="M5.5 9v9.5a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V9" />
|
||||
<path d="M10 13.5h4" />
|
||||
</>
|
||||
),
|
||||
search: () => (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m20 20-4.4-4.4" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
x: () => (
|
||||
<>
|
||||
<path d="m6 6 12 12M18 6 6 18" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
chevDown: () => <path d="m6 9.5 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />,
|
||||
chevRight: () => <path d="m9.5 6 6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />,
|
||||
arrowUpRight: () => (
|
||||
<>
|
||||
<path d="M7 17 17 7M9 7h8v8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
arrowUp: () => <path d="M12 19V5M5.5 11.5 12 5l6.5 6.5" strokeLinecap="round" strokeLinejoin="round" />,
|
||||
arrowDown: () => <path d="M12 5v14M18.5 12.5 12 19l-6.5-6.5" strokeLinecap="round" strokeLinejoin="round" />,
|
||||
play: ({ filled }) =>
|
||||
filled ? (
|
||||
<path d="M8 5.5v13a1 1 0 0 0 1.5.9l11-6.5a1 1 0 0 0 0-1.8l-11-6.5A1 1 0 0 0 8 5.5z" fill="currentColor" stroke="none" />
|
||||
) : (
|
||||
<path d="M8.5 5.8v12.4a.6.6 0 0 0 .9.5l10-6.2a.6.6 0 0 0 0-1L9.4 5.3a.6.6 0 0 0-.9.5z" fill="currentColor" stroke="none" />
|
||||
),
|
||||
refresh: () => (
|
||||
<>
|
||||
<path d="M20 12a8 8 0 1 1-2.3-5.6" strokeLinecap="round" />
|
||||
<path d="M20 3.5V6.6a.4.4 0 0 1-.4.4H16.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
spinner: () => (
|
||||
<>
|
||||
<path d="M12 3a9 9 0 1 0 9 9" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
alert: ({ filled }) =>
|
||||
filled ? (
|
||||
<path
|
||||
d="M12 2.6 23 20.8a1 1 0 0 1-.9 1.5H1.9a1 1 0 0 1-.9-1.5L12 2.6zM12 10v4.6m0 3.1v.1"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
opacity="0.25"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<path d="M12 3.2 21.7 20a1 1 0 0 1-.85 1.5H3.15A1 1 0 0 1 2.3 20L12 3.2z" />
|
||||
<path d="M12 9.5v4.4M12 17.4v.1" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
check: () => <path d="m5 12.5 4.5 4.5L19 7.5" strokeLinecap="round" strokeLinejoin="round" />,
|
||||
info: () => (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5.5M12 7.6v.1" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
database: () => (
|
||||
<>
|
||||
<ellipse cx="12" cy="5.5" rx="7.5" ry="3" />
|
||||
<path d="M4.5 5.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
|
||||
<path d="M4.5 11.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
|
||||
</>
|
||||
),
|
||||
calendar: () => (
|
||||
<>
|
||||
<rect x="3.5" y="5" width="17" height="15.5" rx="2" />
|
||||
<path d="M8 3v4M16 3v4M3.5 10h17" />
|
||||
</>
|
||||
),
|
||||
target: () => (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<circle cx="12" cy="12" r="4.5" />
|
||||
<circle cx="12" cy="12" r="0.8" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
chartLine: () => (
|
||||
<>
|
||||
<path d="M3.5 3.5v17h17" />
|
||||
<path d="m6.5 15 4-5 3.5 3 4.5-6.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
scale: () => (
|
||||
<>
|
||||
<path d="M12 4v16M8 20h8" />
|
||||
<path d="m5 8 7-3 7 3-2.2 3H7.2L5 8z" />
|
||||
<path d="M3.5 12.2 2 11l3-3M20.5 12.2 22 11l-3-3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
book: () => (
|
||||
<>
|
||||
<path d="M4.5 5A2.5 2.5 0 0 1 7 2.5h12V18H7a2.5 2.5 0 0 0-2.5 2.5V5z" />
|
||||
<path d="M4.5 20.5A2.5 2.5 0 0 1 7 18h12" />
|
||||
<path d="M9.5 7h6" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
link: () => (
|
||||
<>
|
||||
<path d="M10 14.5 14.5 10" strokeLinecap="round" />
|
||||
<path d="M8.2 15.8 5.8 18.2a2.8 2.8 0 1 1-3.9-3.9l4-4a2.8 2.8 0 0 1 3.9 0" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M15.8 8.2l2.4-2.4a2.8 2.8 0 1 1 3.9 3.9l-4 4a2.8 2.8 0 0 1-3.9 0" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
spark: () => (
|
||||
<>
|
||||
<path d="M12 3.5 13.8 9l5.7 1.4-5.7 1.4L12 17.5l-1.8-5.7L4.5 10.4 10.2 9 12 3.5z" fill="currentColor" stroke="none" opacity="0.35" />
|
||||
<path d="M19 15.5l.9 2.6 2.6.9-2.6.9-.9 2.6-.9-2.6-2.6-.9 2.6-.9.9-2.6z" fill="currentColor" stroke="none" />
|
||||
</>
|
||||
),
|
||||
clock: () => (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M12 7.5V12l3 2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</>
|
||||
),
|
||||
filter: () => (
|
||||
<>
|
||||
<path d="M4 6h16M7 12h10M10 18h4" strokeLinecap="round" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
/** 填充态(部分图标支持实心强调) */
|
||||
filled?: boolean;
|
||||
}
|
||||
|
||||
export function Icon({ name, size = 18, filled = false, ...rest }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className="ic"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.6}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...rest}
|
||||
>
|
||||
{P[name]({ filled })}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* 设计系统 UI 基元(纯展示,无副作用):页头 / 卡片 / 指标卡 / 徽标 /
|
||||
* 按钮 / 表单字段 / 提示条 / 空态 / 骨架 / 进度 / 回测结果区块。
|
||||
*/
|
||||
import { useId } from "react";
|
||||
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from "react";
|
||||
import type { BacktestSummary } from "@/lib/types";
|
||||
import { Icon, type IconName } from "@/components/icons";
|
||||
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/* ---------- 页头 ---------- */
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
sub,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
sub?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">{title}</h1>
|
||||
{sub ? <p className="page-sub">{sub}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="page-actions">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 卡片 ---------- */
|
||||
|
||||
export function Card({
|
||||
title,
|
||||
sub,
|
||||
icon,
|
||||
tools,
|
||||
children,
|
||||
flush = false,
|
||||
className,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
sub?: ReactNode;
|
||||
icon?: IconName;
|
||||
tools?: ReactNode;
|
||||
children: ReactNode;
|
||||
flush?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const hasHead = title != null || tools != null;
|
||||
return (
|
||||
<section className={cx("card", className)}>
|
||||
{hasHead ? (
|
||||
<div className="card__head">
|
||||
<div className="card__title">
|
||||
{icon ? <Icon name={icon} size={17} /> : null}
|
||||
<span>
|
||||
{title}
|
||||
{sub ? <span className="card__sub"> {sub}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
{tools ? <div className="card__tools">{tools}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={flush ? "card__body--flush" : "card__body"}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 指标卡 ---------- */
|
||||
|
||||
export function Metric({
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
sub,
|
||||
tone = "plain",
|
||||
small = false,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
icon?: IconName;
|
||||
value: ReactNode;
|
||||
sub?: ReactNode;
|
||||
tone?: "plain" | "pos" | "neg" | "warn" | "accent";
|
||||
small?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="metric">
|
||||
<div className="metric__label">
|
||||
{icon ? <Icon name={icon} size={14} /> : null}
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={cx(
|
||||
"metric__value",
|
||||
small && "metric__value--sm",
|
||||
tone !== "plain" && `tone-${tone}`,
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{sub ? <div className="metric__sub">{sub}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 徽标 ---------- */
|
||||
|
||||
export function Pill({
|
||||
tone = "default",
|
||||
children,
|
||||
icon,
|
||||
}: {
|
||||
tone?: "default" | "accent" | "pos" | "neg" | "warn" | "violet";
|
||||
children: ReactNode;
|
||||
icon?: IconName;
|
||||
}) {
|
||||
return (
|
||||
<span className={cx("pill", tone !== "default" && `pill--${tone}`)}>
|
||||
{icon ? <Icon name={icon} size={12} /> : null}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 按钮 ---------- */
|
||||
|
||||
interface BtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "ghost" | "danger";
|
||||
size?: "sm" | "md";
|
||||
icon?: IconName;
|
||||
loading?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Btn({
|
||||
variant = "ghost",
|
||||
size = "md",
|
||||
icon,
|
||||
loading = false,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: BtnProps) {
|
||||
return (
|
||||
<button
|
||||
className={cx(
|
||||
"btn",
|
||||
variant === "primary" && "btn--primary",
|
||||
variant === "danger" && "btn--danger",
|
||||
size === "sm" && "btn--sm",
|
||||
className,
|
||||
)}
|
||||
disabled={rest.disabled || loading}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? <Icon name="spinner" size={14} className="spin" /> : icon ? <Icon name={icon} size={15} /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 表单 ---------- */
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
htmlFor,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
hint?: ReactNode;
|
||||
htmlFor?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cx("field", className)}>
|
||||
<label className="field__label" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint ? <span className="field__hint">{hint}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
mono?: boolean;
|
||||
}
|
||||
|
||||
/** 统一样式的 input(含原生控件),mono 用于代码/数值输入。 */
|
||||
export function TextInput({ className, mono, ...rest }: TextInputProps) {
|
||||
return <input className={cx("input", mono && "mono", className)} {...rest} />;
|
||||
}
|
||||
|
||||
export function SearchBox({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
placeholder,
|
||||
label,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onClear?: () => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const inputId = useId();
|
||||
return (
|
||||
<div className="searchbox" role="search">
|
||||
<label className="sr-only" htmlFor={inputId}>
|
||||
{label ?? "搜索"}
|
||||
</label>
|
||||
<Icon name="search" size={16} />
|
||||
<TextInput
|
||||
id={inputId}
|
||||
type="search"
|
||||
className="input"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
{value && onClear ? (
|
||||
<button type="button" className="icon-btn clear" aria-label="清空搜索" onClick={onClear}>
|
||||
<Icon name="x" size={13} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 提示条 / 空态 / 加载 ---------- */
|
||||
|
||||
export function Banner({
|
||||
tone = "info",
|
||||
children,
|
||||
icon,
|
||||
}: {
|
||||
tone?: "error" | "warn" | "info" | "muted";
|
||||
children: ReactNode;
|
||||
icon?: IconName;
|
||||
}) {
|
||||
const ic: IconName =
|
||||
icon ?? (tone === "error" || tone === "warn" ? "alert" : tone === "muted" ? "info" : "info");
|
||||
return (
|
||||
<div className={cx("banner", `banner--${tone}`)} role={tone === "error" ? "alert" : undefined}>
|
||||
<Icon name={ic} size={16} />
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({
|
||||
icon = "archive",
|
||||
title,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
icon?: IconName;
|
||||
title: ReactNode;
|
||||
hint?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<Icon name={icon} size={30} />
|
||||
<strong>{title}</strong>
|
||||
{hint ? <p className="hint" style={{ maxWidth: 460, margin: "4px auto 0" }}>{hint}</p> : null}
|
||||
{children ? <div style={{ marginTop: 14 }}>{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonLines({ n = 3, height = 14 }: { n?: number; height?: number }) {
|
||||
return (
|
||||
<div className="stack" aria-hidden="true">
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="skeleton"
|
||||
style={{ height, width: i === n - 1 ? "62%" : "100%" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Loading({ label = "加载中…" }: { label?: string }) {
|
||||
return (
|
||||
<div className="row" style={{ color: "var(--text-2)", padding: "10px 0" }} role="status">
|
||||
<Icon name="spinner" size={16} className="spin" />
|
||||
<span className="hint">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Progress({
|
||||
value,
|
||||
label,
|
||||
}: {
|
||||
/** 0-100 */
|
||||
value: number;
|
||||
label?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{label ? (
|
||||
<div className="hint" style={{ marginBottom: 2 }}>
|
||||
{label}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="progress"
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(value)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div className="progress__bar" style={{ width: `${Math.max(0, Math.min(100, value))}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 数值 ---------- */
|
||||
|
||||
export function Signed({
|
||||
value,
|
||||
suffix = "",
|
||||
digits = 2,
|
||||
arrow = true,
|
||||
}: {
|
||||
value: number;
|
||||
suffix?: string;
|
||||
digits?: number;
|
||||
arrow?: boolean;
|
||||
}) {
|
||||
const cls = value > 0 ? "tone-pos" : value < 0 ? "tone-neg" : "";
|
||||
return (
|
||||
<span className={cx("num delta", cls)}>
|
||||
{arrow ? (value > 0 ? <Icon name="arrowUp" size={12} /> : value < 0 ? <Icon name="arrowDown" size={12} /> : null) : null}
|
||||
{value > 0 ? "+" : ""}
|
||||
{value.toFixed(digits)}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 回测 / 因子结果共用区块 ---------- */
|
||||
|
||||
export function BacktestMetrics({ s }: { s: BacktestSummary }) {
|
||||
return (
|
||||
<div className="metric-grid">
|
||||
<Metric label="期末净值" value={s.final_equity.toLocaleString()} sub={`起始 ${s.initial_capital.toLocaleString()}`} />
|
||||
<Metric label="区间收益" tone={s.total_return_pct >= 0 ? "pos" : "neg"} value={<Signed value={s.total_return_pct} suffix="%" />} sub={`${s.start} ~ ${s.end}`} />
|
||||
<Metric label="年化收益" tone={s.annual_return_pct >= 0 ? "pos" : "neg"} value={<Signed value={s.annual_return_pct} suffix="%" />} />
|
||||
<Metric
|
||||
label="Sharpe"
|
||||
tone={s.sharpe >= 1 ? "pos" : s.sharpe < 0 ? "neg" : "plain"}
|
||||
value={s.sharpe.toFixed(2)}
|
||||
sub={`波动 ${s.volatility_pct.toFixed(2)}%`}
|
||||
/>
|
||||
<Metric label="最大回撤" tone="neg" value={<span className="num">{s.max_drawdown_pct.toFixed(2)}%</span>} />
|
||||
<Metric label="胜率 / 换手" value={`${s.win_rate_pct.toFixed(1)}%`} sub={`交易 ${s.total_trades} 笔 · 平均换手 ${s.avg_turnover_pct.toFixed(0)}%`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 月度收益(热力着色) ---------- */
|
||||
|
||||
export function MonthlyReturnsTable({
|
||||
rows,
|
||||
}: {
|
||||
rows: { year: number; month: number; return_pct: number }[];
|
||||
}) {
|
||||
const byYear = new Map<number, Map<number, number>>();
|
||||
for (const r of rows) {
|
||||
if (!byYear.has(r.year)) byYear.set(r.year, new Map());
|
||||
byYear.get(r.year)!.set(r.month, r.return_pct);
|
||||
}
|
||||
const months = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
const years = [...byYear.keys()].sort();
|
||||
if (years.length === 0) return <Empty title="无月度收益数据" />;
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>年份</th>
|
||||
{months.map((m) => (
|
||||
<th key={m} style={{ textAlign: "right", minWidth: 62 }}>
|
||||
{m}月
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{years.map((y) => {
|
||||
const row = byYear.get(y)!;
|
||||
return (
|
||||
<tr key={y}>
|
||||
<td className="cell-strong">{y}</td>
|
||||
{months.map((m) => {
|
||||
const v = row.get(m);
|
||||
if (v == null)
|
||||
return (
|
||||
<td key={m} style={{ textAlign: "right" }}>
|
||||
<span className="cell-heat flat">—</span>
|
||||
</td>
|
||||
);
|
||||
const cls = v > 0 ? "pos" : v < 0 ? "neg" : "flat";
|
||||
return (
|
||||
<td key={m} style={{ textAlign: "right" }}>
|
||||
<span className={`cell-heat ${cls}`}>
|
||||
{v > 0 ? "+" : ""}
|
||||
{v.toFixed(2)}%
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UnimplementedNote({ items }: { items: string[] }) {
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<Card title="未建模约束(如实标注)" icon="info">
|
||||
<ul className="hint" style={{ margin: 0, paddingLeft: 18 }}>
|
||||
{items.map((u, i) => (
|
||||
<li key={i}>{u}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
// 局域网/远程设备访问 dev server 时,/_next/* 资源按允许来源放行(Next 15.5+)
|
||||
allowedDevOrigins: ["192.168.1.160", "127.0.0.1", "localhost"],
|
||||
// 同源代理:页面请求 /api/* → 后端 FastAPI(默认 127.0.0.1:8000)
|
||||
// 使任何 IP/域名访问前端都无需 CORS 与硬编码后端地址。
|
||||
// 后端地址可用环境变量 BACKEND_API_URL 覆盖(如 0.0.0.0 部署到别的机器)。
|
||||
|
||||
Reference in New Issue
Block a user