diff --git a/frontend/web/app/backtest/page.tsx b/frontend/web/app/backtest/page.tsx index 1f744dc..9c2e5ab 100644 --- a/frontend/web/app/backtest/page.tsx +++ b/frontend/web/app/backtest/page.tsx @@ -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("/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 ( <> -

选股回测

-
-

研究参数(Research Specification)

+ + 最近一次回测已完成 + + ) : null + } + /> + +
- - - - - - - +
- {running && jobId &&
任务 {jobId} 后台执行中,请稍候…
} - {error &&
{error}
} -
- {result && s && ( - <> -
-

- 结果 · {s.total_return_pct.toFixed(2)}%{" "} - = 0 ? "good" : "bad"}> - {s.total_return_pct >= 0 ? "▲" : "▼"} - -

-
-
-
期末净值
-
{s.final_equity.toLocaleString()}
-
-
-
年化收益
-
{s.annual_return_pct.toFixed(2)}%
-
-
-
Sharpe
-
{s.sharpe.toFixed(2)}
-
-
-
最大回撤
-
{s.max_drawdown_pct.toFixed(2)}%
-
-
-
胜率(按交易)
-
{s.win_rate_pct.toFixed(1)}%
-
-
-
交易笔数 / 换手
-
- {s.total_trades} / {s.avg_turnover_pct.toFixed(0)}% -
-
-
-
- -
- + + 任务 {jobId || "排队中…"} 后台执行中,请稍候… + + } />
+ ) : null} + {error ?
{error}
: null} + -
-

月度收益(%)

- - - - - - - - - {result.monthly_returns.map((m) => ( - - - - - ))} - -
月份收益
- {m.year}-{String(m.month).padStart(2, "0")} - = 0 ? "good" : "bad"}> - {m.return_pct.toFixed(2)}% -
-
+ {!result && !running && !error ? ( + + + + ) : null} -
-

最新持仓

- {(() => { - const last = new Map(); - 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 ( -
- {pos.map((p) => ( - - {p.symbol} · {(p.weight * 100).toFixed(0)}% - - ))} -
- ); - })()} -
- -
-

未建模约束(如实标注)

- -
- - )} + {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 ( + <> +
+
+ 完成 + Top N 等权 + {s.start} ~ {s.end} +
+
+ 总收益 +
+
+ + + +
+ 期末 {s.final_equity.toLocaleString()}}> + d.slice(5)} yFmt={(v) => v.toFixed(2)} /> + + 最大 {s.max_drawdown_pct.toFixed(2)}%}> + d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} /> + +
+ + + + + + + {holdings.length === 0 ? ( + + ) : ( +
+ {holdings.map((p) => ( + + {p.symbol} + {(p.weight * 100).toFixed(0)}% + + ))} +
+ )} +
+ + + + ); +} + +function SignedText({ value }: { value: number }) { + const cls = value >= 0 ? "tone-pos" : "tone-neg"; + return ( + + {value > 0 ? "+" : ""} + {value.toFixed(2)}% + + ); +} diff --git a/frontend/web/app/experiments/page.tsx b/frontend/web/app/experiments/page.tsx index ebf0f41..6dae9db 100644 --- a/frontend/web/app/experiments/page.tsx +++ b/frontend/web/app/experiments/page.tsx @@ -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 = { + 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 {k.label}; +} + +function statusView(status: string) { + switch (status) { + case "success": + return ( + + 完成 + + ); + case "running": + return ( + + 运行中 + + ); + case "queued": + return ( + + 排队 + + ); + case "failed": + return ( + + 失败 + + ); + case "cancelled": + return 已取消; + default: + return {status}; + } +} + export default function ExperimentsPage() { const [exps, setExps] = useState([]); + const [loading, setLoading] = useState(true); const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); const [jobId, setJobId] = useState(""); const [jobStatus, setJobStatus] = useState(""); const [error, setError] = useState(""); - function load() { + const load = useCallback(() => { apiGet("/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(`/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 ( <> -

实验归档(Experiment · 可复现)

- {error &&
{error}
} - {jobId && ( -
- 复跑 Job {jobId} → 状态:{jobStatus} -
- )} -
- - - - - - - - - - - - - - {exps.map((e) => ( - - - - - - - - - - ))} - {exps.length === 0 && ( - - - - )} - -
ID类型因子区间摘要代码版本
{e.id}{e.kind}{e.factors.join(", ")} - {e.period ? `${e.period[0]} ~ ${e.period[1]}` : "—"} - {e.summary_text ?? "—"}{e.code_version ?? "—"} - - -
- 暂无实验。先在「回测」或「因子研究」中运行一次,即会自动归档。 -
-
+ 0 ? 共 {exps.length} 个实验 : undefined} + /> - {detail && s && ( -
-

- {detail.id} · {detail.factors.join(", ")} · {detail.period?.[0]}~{detail.period?.[1]} -

-
-
-
总收益
-
{s.total_return_pct.toFixed(2)}%
-
-
-
年化
-
{s.annual_return_pct.toFixed(2)}%
-
-
-
Sharpe
-
{s.sharpe.toFixed(2)}
-
-
-
最大回撤
-
{s.max_drawdown_pct.toFixed(2)}%
-
+ {error ? {error} : null} + + {jobId ? ( + +
+ 任务状态实时轮询;完成后列表自动刷新,可直接展开查看最新结果。
-
- spec(可复现输入):{JSON.stringify(detail.spec).slice(0, 400)}… + + ) : null} + + + {loading ? ( +
+
-
- )} + ) : exps.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + {exps.map((e) => ( + + + + + + + + + + ))} + +
ID类型因子区间摘要代码版本操作
{e.id}{kindView(e.kind)} + + {e.factors.map((f) => ( + + {f} + + ))} + + {e.period ? `${e.period[0]}~${e.period[1]}` : "—"}{e.summary_text ?? "—"}{e.code_version ? {e.code_version} : —} + + open(e.id)}> + 详情 + + rerun(e)} disabled={jobStatus === "running" || jobStatus === "queued" || jobStatus === "submitting"}> + 复跑 + + +
+
+ )} +
+ + {detail ? ( + setDetail(null)}> + + 关闭 + + } + > + {detailLoading ? ( + + ) : ( + <> + {s ? ( +
+ = 0 ? "pos" : "neg"} + value={`${s.total_return_pct >= 0 ? "+" : ""}${s.total_return_pct.toFixed(2)}%`} + /> + = 0 ? "pos" : "neg"} + value={`${s.annual_return_pct >= 0 ? "+" : ""}${s.annual_return_pct.toFixed(2)}%`} + /> + + +
+ ) : ( +
该实验没有可展示的汇总结果(可能是因子测试或历史版本)。
+ )} + {detail.spec ? ( +
+ spec(可复现输入):{JSON.stringify(detail.spec).slice(0, 500)} + {JSON.stringify(detail.spec).length > 500 ? "…" : ""} +
+ ) : null} + + )} +
+ ) : null} ); } diff --git a/frontend/web/app/factors/compose/page.tsx b/frontend/web/app/factors/compose/page.tsx index a95c1e5..91cf924 100644 --- a/frontend/web/app/factors/compose/page.tsx +++ b/frontend/web/app/factors/compose/page.tsx @@ -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("/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 ( <> -

因子组合

-
-

组合方式(如何合成一个分数)

-
    + + {picks.length} 个因子 + + } + /> + + +
    1. 选择若干因子并设置权重(默认 1;低为好方向由引擎自动反向)。
    2. - 每个交易日:每个因子在全体股票横截面上做 z-score 标准化 - (该日缺失因子值的股票不参与该因子得分)。 + 每个交易日:每个因子在全体股票横截面上做 z-score 标准化(当日缺失因子值的股票不参与该因子得分)。 +
    3. +
    4. + 综合得分 = Σ(权重 × 标准化后因子得分),得分越高越优先。
    5. -
    6. 综合得分 = Σ(权重 × 标准化后因子得分),得分越高越优先。
    7. 每期按综合得分取 Top N 等权持有,到下一调仓日再平衡(含成本 / 涨跌停 / 停牌近似)。
    -
+ -
-

选因子 + 设权重

- - - - - - - - - - - - {factors.map((f) => { - const pick = picks.find((p) => p.name === f.name); - return ( - - - - - - - - ); - })} - -
因子方向权重简介
- togglePick(f.name)} - /> - {f.name}{f.direction === "higher_is_better" ? "高为好" : "低为好"} - setWeight(f.name, Number(e.target.value))} - /> - {f.brief ?? f.description}
-
- -
-

回测条件

-
- - - - - - -
- {running && jobId &&
任务 {jobId} 后台执行中,请稍候…
} - {error &&
{error}
} -
- - {result && } - - ); -} - -function ResultView({ result }: { result: BacktestResult }) { - const s = result.summary; - return ( - <> -
-

- 结果 · {s.total_return_pct.toFixed(2)}%{" "} - = 0 ? "good" : "bad"}> - {s.total_return_pct >= 0 ? "▲" : "▼"} - -

-
-
-
期末净值
-
{s.final_equity.toLocaleString()}
-
-
-
年化收益
-
{s.annual_return_pct.toFixed(2)}%
-
-
-
Sharpe
-
{s.sharpe.toFixed(2)}
-
-
-
最大回撤
-
{s.max_drawdown_pct.toFixed(2)}%
-
-
-
胜率
-
{s.win_rate_pct.toFixed(1)}%
-
-
-
交易 / 换手
-
{s.total_trades} / {s.avg_turnover_pct.toFixed(0)}%
-
-
-
- -
- -
- - {result.monthly_returns.length > 0 && ( -
-

月度收益(%)

- + +
+
- - + + + + + - {result.monthly_returns.map((m) => ( - - - - - ))} + {factors.map((f) => { + const pick = picks.find((p) => p.name === f.name); + return ( + + + + + + + + ); + })}
月份收益因子方向权重简介
{m.year}-{String(m.month).padStart(2, "0")}= 0 ? "good" : "bad"}>{m.return_pct.toFixed(2)}%
+ togglePick(f.name)} + aria-label={`选择因子 ${f.name}`} + /> + + {f.name} + + + {f.direction === "higher_is_better" ? "高为好" : "低为好"} + + + setWeight(f.name, Number(e.target.value))} + aria-label={`${f.name} 权重`} + /> + {f.brief ?? f.description}
- )} + -
-

未建模约束(如实标注)

-
    {result.unimplemented.map((u, i) =>
  • {u}
  • )}
+ + {picks.length > 0 ? ( +
+ {picks.map((p) => ( + + {p.name} + 权重 {p.weight} + + + ))} +
+ ) : ( +
+ 尚未选择因子 —— 在上方勾选并设置权重。 +
+ )} + +
+ + setTopN(Number(e.target.value))} + /> + + + + + + setStart(e.target.value)} /> + + + setEnd(e.target.value)} /> + + + + + + {running ? "后台运行中…" : `回测组合(${picks.length} 个因子)`} + +
+ + {running ? ( +
+ + 任务 {jobId || "排队中…"} 后台执行中,完成后结果自动展示在本页。 + + } + /> +
+ ) : null} + {error ?
{error}
: null} +
+ + {result ? : 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 ( + <> +
+
+ + 回测完成 + + Top {params.topN} + {params.rebalance === "monthly" ? "月度调仓" : "周度调仓"} + {params.excludeSt ? 剔除 ST : null} + {params.start} ~ {params.end} +
+
+ 区间收益 +
+ + + +
+ 期末 {s.final_equity.toLocaleString()}}> + d.slice(5)} yFmt={(v) => v.toFixed(2)} /> + + 最大 {s.max_drawdown_pct.toFixed(2)}%}> + d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} /> + +
+ + + + + + ); } diff --git a/frontend/web/app/factors/page.tsx b/frontend/web/app/factors/page.tsx index 9c99e57..b66522a 100644 --- a/frontend/web/app/factors/page.tsx +++ b/frontend/web/app/factors/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); const [checked, setChecked] = useState>(new Set()); const [expanded, setExpanded] = useState>(new Set()); const [start, setStart] = useState(""); const [end, setEnd] = useState(""); const [reports, setReports] = useState>({}); 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("/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(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 ( <> -

因子研究

-
-

因子目录(行情因子 · 无未来函数)

-
- 勾选因子后可多选;点击行展开详细说明。每个因子运行一次独立的 IC/RankIC 测试并归档。 - - 用这些因子搭建选股组合 → 因子组合 - -
- - - - - - - - - - - - {factors.map((f) => ( - toggleCheck(f.name)} - onToggleExpand={() => toggleExpand(f.name)} - /> - ))} - -
因子回看方向简介(用法 / 何时有效)
-
- 计分规则:每因子在每日横截面上 z-score 标准化(低为好自动取负),组合页可按权重叠加得分选股; - 本页单个因子测试各自归档为 Experiment,可去「实验」页查看与复跑。 -
-
+ {reportNames.length}/{selectedCount} 个已出报告} + /> -
-

运行单因子测试(IC / RankIC / 分层)

-
- - - -
- {running && ( -
- 正在运行:{current?.name}({current?.idx}/{current?.total})—— 后台任务 {jobId},请稍候… + {error && !running ? ( + +
{error}
+
+ ) : null} + + 已选 {selectedCount} 个} + flush + > + {loading ? ( +
+
+ ) : factors.length === 0 ? ( + + ) : ( + <> +
+ + + + + + + + + + + + {factors.map((f) => ( + toggleCheck(f.name)} + onToggleExpand={() => toggleExpand(f.name)} + /> + ))} + +
因子回看方向简介(用法 / 何时有效)
+
+
+ 计分规则:每个因子在每日横截面做 z-score 标准化(低为好自动取负);组合页按权重叠加得分选股。 +
+ )} - {error &&
{error}
} -
+ - {Object.entries(reports).map(([name, report]) => { + 执行中 : undefined}> +
+ + setStart(e.target.value)} /> + + + setEnd(e.target.value)} /> + + + {running + ? `运行中 ${processed}/${selectedCount}` + : selectedCount === 0 + ? "请先勾选因子" + : `运行因子测试(${selectedCount} 个)`} + +
+ + {running ? ( +
+ + 正在运行:{current?.name}({current?.idx}/{current?.total})· 后台任务{" "} + {jobId || "排队中…"} + + } + /> +
+ ) : null} +
+ + {reportNames.length === 0 && !running && !error ? ( + + + + ) : null} + + {reportNames.map((name) => { const meta = factors.find((f) => f.name === name); return ( -
-

- {name} · 测试报告 - {meta?.brief ? ( - - {" "}— {meta.brief} - - ) : null} -

- -
+ + 已完成 + + } + > + + ); })} @@ -193,81 +265,102 @@ function FactorRow(props: { return ( <> - { e.stopPropagation(); props.onToggleCheck(); }}> - + e.stopPropagation()}> + - {f.name} - {f.lookback} - {f.direction === "higher_is_better" ? "高为好" : "低为好"} - {f.brief ?? f.description} + + {f.name} + + + {f.lookback} 日 + + + + {f.direction === "higher_is_better" ? "高为好" : "低为好"} + + + {f.brief ?? f.description} - {props.expanded && ( - + {props.expanded ? ( + -
- {f.name} · {f.description} -
- 公式:{f.formula};回看 {f.lookback} 个交易日;频率 {f.frequency}; - 方向:{f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"} +
+ {f.name} · {f.description} +
+ 公式:{f.formula};回看 {f.lookback} 个交易日;频率 {f.frequency}; + 方向: + {f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"}
-
{f.brief}
+ {f.brief ?
{f.brief}
: null}
- )} + ) : 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 ( - <> -
-
-
IC 均值
-
{report.ic_mean.toFixed(4)}
-
-
-
RankIC 均值
-
{report.rank_ic_mean.toFixed(4)}
-
-
-
ICIR
-
{report.icir.toFixed(3)}
-
-
-
正收益占比
-
{report.positive_ratio_pct.toFixed(1)}%
-
-
-
样本日数
-
{report.sample_days}
-
+
+
+ = 0 ? "pos" : "neg"} + /> + = 0 ? "pos" : "neg"} + /> + = 1 ? "pos" : "plain"} + sub="越大越稳定" + /> + = 50 ? "pos" : "warn"} + /> +
- {report.quantile_returns.length > 0 && ( - - - - - {report.quantile_returns.map((q) => ( - - ))} - - - - - - {report.quantile_returns.map((q) => ( - - ))} - - -
分层(1 最低 → 5 最高)Q{q.quantile + 1}
未来 21 日平均收益{q.return_pct.toFixed(2)}%
- )} -
- 读数:IC/RankIC 为正表示与未来收益正相关;ICIR 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。 - 单因子测试≠策略有效,需结合样本外与稳健性分析。 + + {qs.length > 0 ? ( +
+
+ 分层表现(Q1 最低因子值 → Q5 最高,未来 21 日平均收益) +
+
+ {qs.map((q) => { + const v = q.return_pct; + const h = Math.max(4, Math.round((Math.abs(v) / maxAbs) * 72)); + return ( +
+ {v >= 0 ? "+" : ""}{v.toFixed(2)}% +
= 0 ? "is-pos" : "is-neg"}`} style={{ height: h }} /> + Q{q.quantile + 1} +
+ ); + })} +
+
+ ) : null} + +
+ 读数:IC / RankIC 为正表示与未来收益正相关,ICIR 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。 + 单因子测试 ≠ 策略有效,需结合样本外与稳健性分析。
- +
); } diff --git a/frontend/web/app/globals.css b/frontend/web/app/globals.css index edfb01c..e7b9973 100644 --- a/frontend/web/app/globals.css +++ b/frontend/web/app/globals.css @@ -1,168 +1,1292 @@ -@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap"); +/* ============================================================ + qlib-platform 设计系统(ui-ux-pro-max · Financial Terminal Dark) + ------------------------------------------------------------ + 语义令牌 → 组件类:深浅分层、高对比文本、金融语义色 + (涨=绿 / 跌=红 / 警示=琥珀 / 主色=天蓝)、1px 低透明边框、 + 统一 4px 间距刻度、可见焦点环、reduced-motion 友好。 + ============================================================ */ + +/* 本地自托管字体(Noto Sans SC 可变 400–700,见 fonts.css / public/fonts)。 + 不再依赖 fonts.googleapis.com / fonts.gstatic.com 等外部网络。 */ +@import "./fonts.css"; :root { - --bg: #0f172a; - --panel: #1e293b; - --panel-2: #273449; - --text: #e2e8f0; - --muted: #94a3b8; - --accent: #38bdf8; - --good: #34d399; - --bad: #f87171; + color-scheme: dark; + + /* 表面分层(自底向上抬升) */ + --bg-0: #060912; + --bg-1: #0a0f1c; + --bg-2: #101729; + --bg-3: #172035; + --surface-1: #101827; + --surface-2: #141d30; + --surface-3: #1b2540; + + /* 文本 */ + --text-1: #e9eef7; + --text-2: #a8b3c9; + --text-3: #8b98b2; + + /* 边框 / 分隔 */ + --line: rgba(150, 165, 195, 0.13); + --line-strong: rgba(150, 165, 195, 0.28); + + /* 语义色 */ + --accent: #3fb6ff; + --accent-strong: #0d99f2; + --accent-soft: rgba(63, 182, 255, 0.14); + --violet: #8b95ff; + --pos: #3ddc97; + --pos-strong: #1fb871; + --pos-soft: rgba(61, 220, 151, 0.13); + --neg: #ff7a7a; + --neg-soft: rgba(255, 122, 122, 0.13); + --warn: #f5b82e; + --warn-soft: rgba(245, 184, 46, 0.14); + --focus: rgba(63, 182, 255, 0.55); + + /* 圆角 */ + --r-xs: 6px; + --r-sm: 9px; + --r-md: 12px; + --r-lg: 16px; + + /* 间距(4px 节奏) */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 24px; + --sp-6: 32px; + --sp-7: 48px; + + /* 字体 */ + --font-sans: "Noto Sans SC", -apple-system, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif; + --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", + Consolas, "Noto Sans SC", monospace; + + /* 阴影:克制的两层 */ + --shadow-1: 0 1px 0 rgba(255, 255, 255, 0.03) inset, 0 8px 24px rgba(0, 0, 0, 0.25); + --shadow-pop: 0 12px 40px rgba(0, 0, 0, 0.45); + + /* 字号(px 由浏览器缩放) */ + --fs-xs: 12px; + --fs-sm: 13px; + --fs-md: 14px; + --fs-lg: 16px; + --fs-xl: 20px; + --fs-2xl: 26px; + + --nav-w: 236px; } -* { +/* ---------- 基础 ---------- */ + +*, +*::before, +*::after { box-sizing: border-box; } +html { + -webkit-text-size-adjust: 100%; +} + body { margin: 0; - background: var(--bg); - color: var(--text); - font-family: "Noto Sans SC", system-ui, sans-serif; + background: var(--bg-0); + color: var(--text-1); + font-family: var(--font-sans); + font-size: var(--fs-md); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + min-height: 100vh; } -nav { - display: flex; - gap: 18px; - align-items: center; - padding: 12px 24px; - background: var(--panel); - border-bottom: 1px solid #334155; +::selection { + background: var(--accent-soft); +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +button, +input, +select, +textarea { + font: inherit; + color: inherit; +} + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + +/* 视觉隐藏但保留给屏幕阅读器(表单控件 label 用) */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} + +/* ---------- 应用外壳(侧栏 + 内容) ---------- */ + +.shell { + display: grid; + grid-template-columns: var(--nav-w) minmax(0, 1fr); + min-height: 100vh; +} + +/* 左侧导航 */ +.rail { position: sticky; top: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: var(--sp-2); + background: linear-gradient(180deg, rgba(16, 23, 40, 0.96), rgba(10, 15, 28, 0.98)); + border-right: 1px solid var(--line); + padding: var(--sp-4) var(--sp-3); + overflow-y: auto; + z-index: 30; } -nav a { - color: var(--muted); - text-decoration: none; +.brand { + display: flex; + align-items: center; + gap: 10px; + padding: 2px var(--sp-2) var(--sp-3); + font-weight: 700; + letter-spacing: 0.2px; +} + +.brand .logo { + display: inline-flex; + width: 30px; + height: 30px; + align-items: center; + justify-content: center; + border-radius: 9px; + color: #04121f; + background: linear-gradient(135deg, var(--accent), var(--violet)); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14) inset; + flex: none; +} + +.brand small { + display: block; + color: var(--text-3); + font-size: 10px; font-weight: 500; + letter-spacing: 0.4px; + line-height: 1.2; } -nav a:hover, -nav a.active { +.nav-label { + margin: var(--sp-3) var(--sp-2) var(--sp-1); + color: var(--text-3); + font-size: var(--fs-xs); + font-weight: 600; + letter-spacing: 1px; + text-transform: uppercase; +} + +.nav a.nav-link { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 10px; + border-radius: var(--r-sm); + color: var(--text-2); + font-size: var(--fs-sm); + font-weight: 500; + text-decoration: none; + transition: background 0.15s ease, color 0.15s ease; +} + +.nav a.nav-link:hover { + background: rgba(150, 165, 195, 0.09); + color: var(--text-1); + text-decoration: none; +} + +.nav a.nav-link.active { + background: var(--accent-soft); + color: #dff1ff; + box-shadow: inset 0 0 0 1px rgba(63, 182, 255, 0.22); +} + +.nav a.nav-link.active .nav-ic { color: var(--accent); } -nav .brand { - color: var(--text); - font-weight: 700; - margin-right: 8px; +.nav-ic { + display: inline-flex; + color: var(--text-3); + flex: none; + transition: color 0.15s ease; } -main { - max-width: 1200px; - margin: 0 auto; - padding: 24px; +.rail-foot { + margin-top: auto; + padding: var(--sp-3) var(--sp-2) 0; + border-top: 1px solid var(--line); + color: var(--text-3); + font-size: var(--fs-xs); + line-height: 1.5; } +/* 顶栏(窄屏) */ +.topbar { + display: none; +} + +/* 抽屉与遮罩仅窄屏启用:桌面端隐藏,避免它们作为 shell 网格子元素 + 把 main 挤到第二行(导致主内容被压在侧栏下方)。 */ +.drawer, +.scrim { + display: none; +} + +.content { + min-width: 0; + padding: var(--sp-5) clamp(16px, 3vw, 40px) var(--sp-7); +} + +.page { + max-width: 1180px; + margin: 0 auto; +} + +/* 抽屉(窄屏) */ +.scrim { + display: none; +} + +/* ---------- 页头 ---------- */ + +.page-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-4); + margin-bottom: var(--sp-5); + flex-wrap: wrap; +} + +.page-title { + font-size: var(--fs-2xl); + font-weight: 700; + letter-spacing: -0.2px; + line-height: 1.25; +} + +.page-sub { + margin-top: 4px; + color: var(--text-2); + font-size: var(--fs-sm); + max-width: 720px; +} + +.page-actions { + display: flex; + align-items: center; + gap: var(--sp-2); + flex-wrap: wrap; +} + +/* ---------- 卡片 ---------- */ + .card { - background: var(--panel); - border: 1px solid #334155; - border-radius: 10px; - padding: 18px; - margin-bottom: 16px; + background: linear-gradient(180deg, var(--surface-2), var(--surface-1)); + border: 1px solid var(--line); + border-radius: var(--r-md); + box-shadow: var(--shadow-1); + margin-bottom: var(--sp-4); } -h1 { - font-size: 22px; - margin: 0 0 16px; +.card__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--line); } -h2 { - font-size: 16px; - margin: 0 0 12px; +.card__title { + font-size: var(--fs-md); + font-weight: 600; + display: flex; + align-items: center; + gap: var(--sp-2); + color: var(--text-1); } -.muted { - color: var(--muted); - font-size: 13px; +.card__title .ic { + color: var(--text-3); } -.stat-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 12px; +.card__sub { + color: var(--text-3); + font-size: var(--fs-xs); + font-weight: 400; + margin-top: 1px; } -.stat { - background: var(--panel-2); - border-radius: 8px; - padding: 14px; +.card__tools { + display: flex; + align-items: center; + gap: var(--sp-2); + flex: none; } -.stat .label { - color: var(--muted); - font-size: 12px; +.card__body { + padding: var(--sp-4); } -.stat .value { - font-size: 20px; - font-weight: 700; - margin-top: 4px; +.card__body--flush { + padding: 0; } -.good { color: var(--good); } -.bad { color: var(--bad); } - -table { - width: 100%; - border-collapse: collapse; - font-size: 13px; +.card--flat { + background: transparent; + box-shadow: none; } -th, -td { - text-align: left; - padding: 8px 10px; - border-bottom: 1px solid #334155; -} +/* ---------- 按钮 ---------- */ -th { - color: var(--muted); +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 1px solid var(--line-strong); + background: var(--surface-3); + color: var(--text-1); + border-radius: var(--r-sm); + padding: 7px 14px; + font-size: var(--fs-sm); font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease, + transform 0.05s ease, box-shadow 0.15s ease; } -input, -select, -button { - background: var(--panel-2); - color: var(--text); - border: 1px solid #475569; - border-radius: 6px; - padding: 8px 10px; - font-size: 13px; - margin: 2px 6px 2px 0; +.btn:hover { + background: #243152; + border-color: rgba(150, 165, 195, 0.4); + text-decoration: none; } -button.primary { - background: var(--accent); - color: #0f172a; - font-weight: 700; - border: none; +.btn:active { + transform: translateY(0.5px); +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; +} + +.btn--primary { + background: linear-gradient(180deg, #4dc0ff, var(--accent-strong)); + color: #061423; + border-color: transparent; + font-weight: 600; +} + +.btn--primary:hover { + filter: brightness(1.07); + background: linear-gradient(180deg, #4dc0ff, var(--accent-strong)); +} + +.btn--danger { + color: var(--neg); + border-color: rgba(255, 122, 122, 0.35); +} + +.btn--danger:hover { + background: var(--neg-soft); +} + +.btn--sm { + padding: 4px 10px; + font-size: var(--fs-xs); + border-radius: var(--r-xs); +} + +.btn .ic { + flex: none; +} + +/* 图标按钮 */ +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: var(--r-sm); + color: var(--text-2); + background: transparent; + border: 1px solid transparent; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.icon-btn:hover { + background: rgba(150, 165, 195, 0.12); + color: var(--text-1); +} + +/* ---------- 表单 ---------- */ + +.input, +select.input, +textarea.input { + width: 100%; + background: #0d1424; + border: 1px solid var(--line-strong); + color: var(--text-1); + border-radius: var(--r-sm); + padding: 7px 10px; + font-size: var(--fs-sm); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.input:hover { + border-color: rgba(150, 165, 195, 0.42); +} + +.input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.input::placeholder { + color: var(--text-3); +} + +select.input { + appearance: none; + background-image: linear-gradient(45deg, transparent 50%, var(--text-2) 50%), + linear-gradient(135deg, var(--text-2) 50%, transparent 50%); + background-position: calc(100% - 16px) 55%, calc(100% - 11px) 55%; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + padding-right: 28px; +} + +input[type="date"].input { + padding-right: 8px; +} + +input[type="checkbox"] { + width: 15px; + height: 15px; + accent-color: var(--accent); cursor: pointer; } -button.primary:disabled { - opacity: 0.5; - cursor: not-allowed; +input[type="number"].input, +input[type="date"].input { + width: auto; + min-width: 0; } -.error { - color: var(--bad); - font-size: 13px; +.field { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +.field__label { + color: var(--text-2); + font-size: var(--fs-xs); + font-weight: 500; + letter-spacing: 0.2px; +} + +.field__hint { + color: var(--text-3); + font-size: var(--fs-xs); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); + gap: var(--sp-3); + align-items: end; +} + +.form-grid .btn { + align-self: end; + height: 34px; } .row { display: flex; flex-wrap: wrap; - gap: 8px; align-items: center; + gap: var(--sp-2); } -.form-grid { +.between { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + flex-wrap: wrap; +} + +.chart-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 10px; - align-items: end; + grid-template-columns: repeat(auto-fit, minmax(min(430px, 100%), 1fr)); + gap: var(--sp-4); +} + +.chart-grid .card { + margin-bottom: 0; +} + +.stack { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +/* 搜索框 */ +.searchbox { + position: relative; + min-width: min(320px, 100%); +} + +.searchbox .ic { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--text-3); + pointer-events: none; +} + +.searchbox .input { + padding-left: 32px; +} + +.searchbox .clear { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); +} + +/* ---------- 徽标 / 状态 ---------- */ + +.pill { + display: inline-flex; + align-items: center; + gap: 5px; + border-radius: 999px; + padding: 2px 9px; + font-size: var(--fs-xs); + font-weight: 500; + line-height: 1.6; + border: 1px solid transparent; + white-space: nowrap; +} + +.pill--default { + color: var(--text-2); + background: rgba(150, 165, 195, 0.12); + border-color: rgba(150, 165, 195, 0.18); +} + +.pill--accent { + color: #bfe9ff; + background: var(--accent-soft); + border-color: rgba(63, 182, 255, 0.3); +} + +.pill--pos { + color: #9ff0cb; + background: var(--pos-soft); + border-color: rgba(61, 220, 151, 0.3); +} + +.pill--neg { + color: #ffb1b1; + background: var(--neg-soft); + border-color: rgba(255, 122, 122, 0.3); +} + +.pill--warn { + color: #ffe3a3; + background: var(--warn-soft); + border-color: rgba(245, 184, 46, 0.32); +} + +.pill--violet { + color: #cdd2ff; + background: rgba(139, 149, 255, 0.14); + border-color: rgba(139, 149, 255, 0.3); +} + +.tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 7px; + border-radius: var(--r-xs); + background: rgba(150, 165, 195, 0.1); + color: var(--text-2); + font-size: var(--fs-xs); + border: 1px solid var(--line); +} + +.tag--mono { + font-family: var(--font-mono); + font-size: var(--fs-xs); +} + +/* ---------- 指标卡 ---------- */ + +.metric-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: var(--sp-3); + margin-bottom: var(--sp-4); +} + +.metric { + background: linear-gradient(180deg, var(--surface-2), var(--surface-1)); + border: 1px solid var(--line); + border-radius: var(--r-md); + padding: var(--sp-3) var(--sp-4); + position: relative; + overflow: hidden; +} + +.metric__label { + display: flex; + align-items: center; + gap: 7px; + color: var(--text-2); + font-size: var(--fs-xs); + font-weight: 500; +} + +.metric__label .ic { + color: var(--text-3); +} + +.metric__value { + margin-top: 6px; + font-size: 24px; + font-weight: 700; + letter-spacing: -0.3px; + font-variant-numeric: tabular-nums; + line-height: 1.2; +} + +.metric__value--sm { + font-size: var(--fs-lg); +} + +.metric__sub { + margin-top: 4px; + color: var(--text-3); + font-size: var(--fs-xs); + font-variant-numeric: tabular-nums; +} + +.metric__value.tone-pos, +.tone-pos { + color: var(--pos); +} +.metric__value.tone-neg, +.tone-neg { + color: var(--neg); +} +.metric__value.tone-warn, +.tone-warn { + color: var(--warn); +} +.metric__value.tone-accent, +.tone-accent { + color: var(--accent); +} +.metric__value.tone-plain, +.tone-plain { + color: var(--text-1); +} + +/* ---------- 表格 ---------- */ + +.table-wrap { + overflow-x: auto; +} + +table.tbl { + width: 100%; + border-collapse: collapse; + font-size: var(--fs-sm); + font-variant-numeric: tabular-nums; +} + +.tbl th, +.tbl td { + text-align: left; + padding: 9px 12px; + border-bottom: 1px solid var(--line); + vertical-align: middle; +} + +.tbl th { + color: var(--text-3); + font-weight: 600; + font-size: var(--fs-xs); + letter-spacing: 0.4px; + background: rgba(150, 165, 195, 0.045); + white-space: nowrap; +} + +.tbl tbody tr { + transition: background 0.12s ease; +} + +.tbl tbody tr:hover { + background: rgba(150, 165, 195, 0.055); +} + +.tbl tr:last-child td { + border-bottom: none; +} + +.tbl tbody tr.row-active { + background: var(--accent-soft); +} + +.cell-mono, +.mono { + font-family: var(--font-mono); +} + +.cell-strong { + font-weight: 600; + color: var(--text-1); +} + +.tbl .num, +.num { + font-variant-numeric: tabular-nums; +} + +.tbl .tone-pos, +.tbl td.tone-pos { + color: var(--pos); +} +.tbl .tone-neg, +.tbl td.tone-neg { + color: var(--neg); +} +.tbl .tone-warn, +.tbl td.tone-warn { + color: var(--warn); +} + +/* 扩展行 / 详情内嵌 */ +.expand-cell { + background: rgba(10, 15, 28, 0.6); +} + +.expand-cell > div { + border-left: 2px solid var(--accent-soft); + margin: 4px 12px 12px; + padding: 10px 12px; + border-radius: var(--r-sm); + background: var(--bg-1); +} + +/* ---------- 进度 / 加载 / 空态 ---------- */ + +.progress { + height: 6px; + border-radius: 999px; + background: rgba(150, 165, 195, 0.16); + overflow: hidden; + margin: var(--sp-2) 0; +} + +.progress__bar { + height: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--accent-strong), var(--accent)); + transition: width 0.3s ease; +} + +.skeleton { + border-radius: var(--r-sm); + background: linear-gradient(90deg, rgba(150, 165, 195, 0.08), rgba(150, 165, 195, 0.16), rgba(150, 165, 195, 0.08)); + background-size: 200% 100%; + animation: shimmer 1.4s infinite linear; +} + +@keyframes shimmer { + to { + background-position: -200% 0; + } +} + +.empty { + padding: var(--sp-6) var(--sp-4); + text-align: center; + color: var(--text-3); +} + +.empty .ic { + color: var(--text-3); + opacity: 0.6; + margin-bottom: var(--sp-2); +} + +.empty strong { + display: block; + color: var(--text-2); + margin-bottom: 2px; +} + +/* 提示条 */ +.banner { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 10px 13px; + border-radius: var(--r-sm); + font-size: var(--fs-sm); + margin-bottom: var(--sp-3); + border: 1px solid; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.banner .ic { + flex: none; + margin-top: 2px; +} + +.banner--error { + color: #ffc9c9; + background: var(--neg-soft); + border-color: rgba(255, 122, 122, 0.32); +} + +.banner--warn { + color: #ffe6ae; + background: var(--warn-soft); + border-color: rgba(245, 184, 46, 0.32); +} + +.banner--info { + color: #d6efff; + background: var(--accent-soft); + border-color: rgba(63, 182, 255, 0.3); +} + +.banner--muted { + color: var(--text-2); + background: rgba(150, 165, 195, 0.08); + border-color: var(--line); +} + +/* 说明文本 */ +.hint { + color: var(--text-3); + font-size: var(--fs-xs); + line-height: 1.7; +} + +/* 数值条(分层收益等) */ +.vbars { + display: flex; + align-items: flex-end; + gap: 8px; + height: 96px; + padding: var(--sp-3) 0; +} + +.vbars__item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + min-width: 0; +} + +.vbars__bar { + width: min(46px, 70%); + min-height: 3px; + border-radius: 4px 4px 0 0; + transition: height 0.3s ease; +} + +.vbars__bar.is-pos { + background: linear-gradient(180deg, var(--pos), var(--pos-strong)); +} +.vbars__bar.is-neg { + background: linear-gradient(180deg, #ff9d9d, var(--neg)); +} + +.vbars__val { + font-size: var(--fs-xs); + color: var(--text-1); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.vbars__cap { + color: var(--text-3); + font-size: var(--fs-xs); +} + +/* 选中清单 chips */ +.chips { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--surface-3); + border: 1px solid var(--line-strong); + border-radius: 999px; + padding: 3px 6px 3px 11px; + font-size: var(--fs-xs); + color: var(--text-2); +} + +.chip b { + color: var(--text-1); + font-weight: 600; +} + +.chip__x { + display: inline-flex; + padding: 3px; + border-radius: 50%; + cursor: pointer; + color: var(--text-3); + background: transparent; + border: none; +} + +.chip__x:hover { + color: var(--neg); + background: var(--neg-soft); +} + +/* 月度收益着色单元格 */ +.cell-heat { + font-variant-numeric: tabular-nums; + border-radius: var(--r-xs); + padding: 2px 7px; + display: inline-block; + min-width: 58px; + text-align: right; +} + +.cell-heat.pos { + color: #c7ffe4; + background: var(--pos-soft); +} +.cell-heat.neg { + color: #ffd2d2; + background: var(--neg-soft); +} +.cell-heat.flat { + color: var(--text-2); + background: rgba(150, 165, 195, 0.1); +} + +/* 旋转加载图标 */ +.spin { + animation: spin 0.9s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.ic { + display: inline-block; + flex: none; + vertical-align: -2px; +} + +.quick-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: var(--sp-3); + margin-bottom: var(--sp-4); +} + +.quick-card { + display: flex; + gap: 13px; + align-items: flex-start; + padding: var(--sp-4); + background: linear-gradient(180deg, var(--surface-2), var(--surface-1)); + border: 1px solid var(--line); + border-radius: var(--r-md); + color: var(--text-1); + text-decoration: none; + transition: border-color 0.15s ease, transform 0.12s ease, background 0.15s ease; +} + +.quick-card:hover { + border-color: rgba(63, 182, 255, 0.4); + text-decoration: none; + background: linear-gradient(180deg, var(--surface-3), var(--surface-2)); +} + +.quick-card .quick-ic { + display: inline-flex; + width: 38px; + height: 38px; + flex: none; + align-items: center; + justify-content: center; + border-radius: 11px; + background: var(--accent-soft); + color: var(--accent); +} + +.quick-card--violet .quick-ic { + background: rgba(139, 149, 255, 0.14); + color: var(--violet); +} +.quick-card--pos .quick-ic { + background: var(--pos-soft); + color: var(--pos); +} +.quick-card--warn .quick-ic { + background: var(--warn-soft); + color: var(--warn); +} + +.quick-card h3 { + font-size: var(--fs-md); + font-weight: 600; + margin-bottom: 2px; +} + +.quick-card p { + color: var(--text-2); + font-size: var(--fs-xs); + line-height: 1.55; +} + +.quick-card .go { + margin-top: 8px; + font-size: var(--fs-xs); + font-weight: 600; + color: var(--accent); + display: inline-flex; + align-items: center; + gap: 4px; +} + +/* 运行面板(回测/组合配置) */ +.runpanel { + position: sticky; + bottom: 12px; + z-index: 20; + display: flex; + align-items: center; + gap: var(--sp-3); + flex-wrap: wrap; + background: rgba(13, 19, 35, 0.92); + backdrop-filter: blur(8px); + border: 1px solid var(--line-strong); + border-radius: var(--r-md); + padding: var(--sp-3) var(--sp-4); + box-shadow: var(--shadow-pop); +} + +.runpanel__info { + color: var(--text-2); + font-size: var(--fs-xs); + flex: 1; + min-width: 200px; +} + +.runpanel__info b { + color: var(--text-1); +} + +/* 数值差箭头 */ +.delta { + display: inline-flex; + align-items: center; + gap: 3px; + font-weight: 600; +} + +/* 列表(说明/步骤) */ +.steplist { + margin: 0; + padding-left: 18px; + display: flex; + flex-direction: column; + gap: 6px; + color: var(--text-2); + font-size: var(--fs-sm); +} + +.steplist b { + color: var(--text-1); +} + +/* ---------- 响应式 ---------- */ + +@media (max-width: 940px) { + :root { + --nav-w: 0px; + } + + .shell { + grid-template-columns: 1fr; + } + + .rail { + display: none; + } + + .topbar { + position: sticky; + top: 0; + z-index: 40; + display: flex; + align-items: center; + gap: var(--sp-2); + height: 52px; + padding: 0 var(--sp-3); + background: rgba(10, 15, 28, 0.92); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); + } + + .topbar .brand { + padding: 0; + } + + .drawer { + position: fixed; + inset: 0 auto 0 0; + width: min(78vw, 290px); + background: var(--bg-1); + border-right: 1px solid var(--line-strong); + z-index: 60; + transform: translateX(-102%); + transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.3, 1); + display: flex; + flex-direction: column; + padding: var(--sp-3); + gap: var(--sp-1); + overflow-y: auto; + box-shadow: var(--shadow-pop); + } + + .drawer.open { + transform: translateX(0); + } + + .drawer .brand { + padding-bottom: var(--sp-3); + margin-bottom: var(--sp-1); + border-bottom: 1px solid var(--line); + } + + .scrim { + display: block; + position: fixed; + inset: 0; + background: rgba(3, 6, 12, 0.6); + z-index: 50; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + } + + .scrim.show { + opacity: 1; + pointer-events: auto; + } + + .content { + padding: var(--sp-4) 14px var(--sp-6); + } + + .runpanel { + position: static; + border-radius: var(--r-md); + } +} + +@media (max-width: 560px) { + .metric-grid { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: var(--sp-2); + } + + .metric__value { + font-size: 20px; + } + + .form-grid { + grid-template-columns: 1fr 1fr; + } + + .page-head { + flex-direction: column; + } } diff --git a/frontend/web/app/layout.tsx b/frontend/web/app/layout.tsx index 59b7f67..d8ce566 100644 --- a/frontend/web/app/layout.tsx +++ b/frontend/web/app/layout.tsx @@ -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 ( - -
{children}
+ {children} ); diff --git a/frontend/web/app/page.tsx b/frontend/web/app/page.tsx index 38564dd..a9d4e18 100644 --- a/frontend/web/app/page.tsx +++ b/frontend/web/app/page.tsx @@ -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(null); - const [factorCount, setFactorCount] = useState(null); - const [error, setError] = useState(""); + const [st, setSt] = useState({ + stocks: null, + factors: null, + loading: true, + error: "", + }); useEffect(() => { - Promise.all([ - apiGet("/stocks?limit=500"), - apiGet("/factors"), - ]) + let alive = true; + Promise.all([apiGet("/stocks?limit=500"), apiGet("/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 ( <> -

总览

- {error &&
后端不可达:{error}
} -
-
-
股票池
-
{stockCount ?? "—"}
+ + + {st.error ? ( + + 后端不可达:{st.error}。请确认 FastAPI 服务已启动(scripts/dev.sh start)。 + + ) : null} + + {st.loading ? ( +
+
+ +
-
-
内置因子
-
{factorCount ?? "—"}
+ ) : ( +
+ +
-
-
-

说明

-
- 个人 A 股量化研究平台(Phase 3 Web)。 - 顶部导航进入「股票池 / 因子研究 / 回测」;研究基于本地 SQLite 行情 - (Tushare 同步,20 只权重股 2023–2024 日线),因子测试与回测均由后端 - Research Specification 驱动并返回标准化结果。 + )} + + +
+ {LINKS.map((l) => ( + + + + + +

{l.title}

+

{l.desc}

+ + 进入 + +
+ + ))}
-
+ + + +
    +
  • + 因子/回测请求经 异步 Job 后台执行并自动归档为实验(Experiment),可在「实验」页查看与复跑。 +
  • +
  • + 数据底座为本地 SQLite + Parquet:行情经 sync daily --resume 增量同步; + 新浪数据仅在「两边一致」校验通过后兜底入库(source=sina)。 +
  • +
  • + 研究默认禁止未来函数:财务按公告日可见、因子按已披露数据计算;回测含手续费 / 印花税 / 涨跌停等近似约束(如实标注)。 +
  • +
+
); } diff --git a/frontend/web/app/stocks/page.tsx b/frontend/web/app/stocks/page.tsx index 5c3a7c0..a7b2873 100644 --- a/frontend/web/app/stocks/page.tsx +++ b/frontend/web/app/stocks/page.tsx @@ -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 ( + + 上市 + + ); + case "D": + return 退市; + case "P": + return 暂停; + default: + return {s.status}; + } +} export default function StocksPage() { const [stocks, setStocks] = useState([]); const [q, setQ] = useState(""); + const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [reloadKey, setReloadKey] = useState(0); + const first = useRef(true); - useEffect(() => { - apiGet(`/stocks?limit=300&q=${encodeURIComponent(q)}`) - .then(setStocks) - .catch((e: Error) => setError(e.message)); + const load = useCallback(async () => { + setError(""); + try { + const list = await apiGet( + `/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 ( <> -

股票池

-
- + + 共 {stocks.length} 条 + + + + } + /> + + {error ? ( + 加载失败:{error} + ) : null} + +
+ setQ(e.target.value)} + onChange={setQ} + onClear={() => setQ("")} + placeholder="按代码 / 名称搜索,如 600519 或 茅台" + label="搜索股票" /> - 共 {stocks.length} 条
- {error &&
{error}
} -
- - - - - - - - - - - - - {stocks.map((s) => ( - - - - - - - + + +
+
代码名称行业地区上市日状态
{s.symbol}{s.name}{s.industry ?? "—"}{s.area ?? "—"}{s.list_date}{s.status}
+ + + + + + + + + - ))} - -
代码名称市场行业地区上市日状态
-
+ + + {stocks.map((s) => ( + + {s.symbol} + {s.name} + + {marketOf(s.symbol)} + + {s.industry ? {s.industry} : —} + {s.area ?? "—"} + {s.list_date} + {statusView(s)} + + ))} + + +
+ {loading && !error && stocks.length === 0 ? ( +
+ +
+ ) : null} + {!loading && !error && stocks.length === 0 ? ( + + ) : null} + {stocks.length > 0 ? ( +
+ 显示前 {stocks.length} 条(接口上限 {LIMIT}) + + + 上市 + + + + 暂停 + + + + 退市 + +
+ ) : null} + ); } + +function Dot({ color }: { color: string }) { + return ( +