diff --git a/backend/app/api/factors.py b/backend/app/api/factors.py index 1cbdba3..f5d3c22 100644 --- a/backend/app/api/factors.py +++ b/backend/app/api/factors.py @@ -15,6 +15,7 @@ def list_factor_catalog() -> list[dict]: { "name": d.name, "description": d.description, + "brief": d.brief, "formula": d.formula, "frequency": d.frequency, "lookback": d.lookback, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 405db12..0c6417a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -130,7 +130,9 @@ def get_settings() -> Settings: except (TypeError, ValueError): job_memory_limit_gb = 6 try: - job_max_concurrent = int(os.environ.get("QLIB_JOB_MAX_CONCURRENT") or job_cfg.get("max_concurrent_jobs") or 2) + job_max_concurrent = int( + os.environ.get("QLIB_JOB_MAX_CONCURRENT") or job_cfg.get("max_concurrent_jobs") or 2 + ) except (TypeError, ValueError): job_max_concurrent = 2 diff --git a/backend/app/quant/factors.py b/backend/app/quant/factors.py index 1a046be..53a2ee0 100644 --- a/backend/app/quant/factors.py +++ b/backend/app/quant/factors.py @@ -21,6 +21,7 @@ class FactorDef: name: str description: str formula: str + brief: str = "" # 一句话使用简介(面向用户:怎么用、什么时候有效) frequency: str = "daily" lookback: int = 20 direction: str = "higher_is_better" # | lower_is_better @@ -82,14 +83,26 @@ def _rolling_vol(prices: pd.DataFrame, lookback: int) -> pd.DataFrame: @register( - FactorDef("momentum_20", "过去 20 个交易日收益率", "close / close.shift(20) - 1", lookback=20) + FactorDef( + "momentum_20", + "过去 20 个交易日收益率", + "close / close.shift(20) - 1", + brief="短期动量:近一个月强势股延续性较强,适合趋势延续环境(牛市中段);震荡市易追高。", + lookback=20, + ) ) def _momentum_20(fields: dict[str, pd.DataFrame]) -> pd.DataFrame: return _rolling_return(fields["close"], 20) @register( - FactorDef("momentum_60", "过去 60 个交易日收益率", "close / close.shift(60) - 1", lookback=60) + FactorDef( + "momentum_60", + "过去 60 个交易日收益率", + "close / close.shift(60) - 1", + brief="中期动量:A 股常见有效时段(约 1~3 个月),趋势行情首选;需结合市场阶段判断方向。", + lookback=60, + ) ) def _momentum_60(fields: dict[str, pd.DataFrame]) -> pd.DataFrame: return _rolling_return(fields["close"], 60) @@ -97,7 +110,11 @@ def _momentum_60(fields: dict[str, pd.DataFrame]) -> pd.DataFrame: @register( FactorDef( - "momentum_120", "过去 120 个交易日收益率", "close / close.shift(120) - 1", lookback=120 + "momentum_120", + "过去 120 个交易日收益率", + "close / close.shift(120) - 1", + brief="长期动量:反映近半年强势,适合大级别趋势;换手慢、回撤修复慢,弱市慎用。", + lookback=120, ) ) def _momentum_120(fields: dict[str, pd.DataFrame]) -> pd.DataFrame: @@ -109,6 +126,7 @@ def _momentum_120(fields: dict[str, pd.DataFrame]) -> pd.DataFrame: "volatility_20", "过去 20 个交易日收益率波动率", "std(pct_change, 20)", + brief="低波防御(方向 lower_is_better):近月波动小的股票抗跌,弱市/熊市阶段相对占优。", lookback=20, direction="lower_is_better", ) @@ -122,6 +140,7 @@ def _volatility_20(fields: dict[str, pd.DataFrame]) -> pd.DataFrame: "volatility_60", "过去 60 个交易日收益率波动率", "std(pct_change, 60)", + brief="低波动(方向 lower_is_better):近一季低波组合长期回测常有超额,是防御型核心因子。", lookback=60, direction="lower_is_better", ) @@ -135,6 +154,7 @@ def _volatility_60(fields: dict[str, pd.DataFrame]) -> pd.DataFrame: "close_to_high_60", "收盘价相对 60 日最高价的接近程度", "close / rolling_max(high, 60)", + brief="贴近 60 日高点(接近新高):趋势确认型强势股,常与动量互补;需配合市场热度判断。", lookback=60, requires=("close", "high"), ) @@ -149,6 +169,7 @@ def _close_to_high_60(fields: dict[str, pd.DataFrame]) -> pd.DataFrame: "volume_ratio_5_60", "量比:5 日均量 / 60 日均量", "mean(volume, 5) / mean(volume, 60)", + brief="量比放大提示资金关注(短线活跃型);高换手也伴随更高波动,注意与波动因子搭配。", lookback=60, requires=("volume",), ) @@ -163,6 +184,7 @@ def _volume_ratio_5_60(fields: dict[str, pd.DataFrame]) -> pd.DataFrame: "ma_bias_20", "20 日均线乖离率", "(close - ma(close, 20)) / ma(close, 20)", + brief="20 日均线乖离:上行趋势中正乖离偏强;乖离过大易回落,需警惕过热。", lookback=20, ) ) @@ -177,6 +199,7 @@ def _ma_bias_20(fields: dict[str, pd.DataFrame]) -> pd.DataFrame: "reversal_5", "短期反转:过去 5 日收益率取负(越低越接近超跌)", "-1 * (close / close.shift(5) - 1)", + brief="短期反转(方向 higher_is_better):前期跌幅大的超跌反弹机会,适合震荡/修复行情。", lookback=5, ) ) diff --git a/backend/tests/test_quant_engine.py b/backend/tests/test_quant_engine.py index 0358609..88b4a01 100644 --- a/backend/tests/test_quant_engine.py +++ b/backend/tests/test_quant_engine.py @@ -76,9 +76,7 @@ class TestBacktestMain: engine = LocalEngine() momentum = _spec() # momentum_20 assert engine.required_columns(momentum) == {"close"} - volume = _spec().model_copy( - update={"factors": [FactorSpec(name="volume_ratio_5_60")]} - ) + volume = _spec().model_copy(update={"factors": [FactorSpec(name="volume_ratio_5_60")]}) assert engine.required_columns(volume) == {"close", "volume"} unknown = _spec().model_copy(update={"factors": [FactorSpec(name="no_such")]}) # 未知因子不参与列裁剪,交由执行期统一报错 diff --git a/frontend/web/app/backtest/page.tsx b/frontend/web/app/backtest/page.tsx index 13a018e..1f744dc 100644 --- a/frontend/web/app/backtest/page.tsx +++ b/frontend/web/app/backtest/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; 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 { LineChart } from "@/components/LineChart"; export default function BacktestPage() { @@ -11,8 +12,8 @@ export default function BacktestPage() { const [factor, setFactor] = useState("momentum_60"); const [topN, setTopN] = useState(5); const [rebalance, setRebalance] = useState<"monthly" | "weekly">("monthly"); - const [start, setStart] = useState("2024-03-01"); - const [end, setEnd] = useState("2024-12-31"); + const [start, setStart] = useState(""); + const [end, setEnd] = useState(""); const [excludeSt, setExcludeSt] = useState(true); const [result, setResult] = useState(null); const [running, setRunning] = useState(false); @@ -23,6 +24,9 @@ export default function BacktestPage() { apiGet("/factors") .then(setFactors) .catch((e: Error) => setError(e.message)); + const { start: s, end: e } = recentRange(); + setStart(s); + setEnd(e); }, []); async function run() { diff --git a/frontend/web/app/factors/compose/page.tsx b/frontend/web/app/factors/compose/page.tsx new file mode 100644 index 0000000..a95c1e5 --- /dev/null +++ b/frontend/web/app/factors/compose/page.tsx @@ -0,0 +1,260 @@ +"use client"; + +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"; + +interface Pick { + name: string; + weight: number; +} + +export default function ComposePage() { + const [factors, setFactors] = useState([]); + const [picks, setPicks] = useState([]); + const [topN, setTopN] = useState(10); + const [rebalance, setRebalance] = useState<"monthly" | "weekly">("monthly"); + const [excludeSt, setExcludeSt] = useState(true); + const [start, setStart] = useState(""); + const [end, setEnd] = useState(""); + const [result, setResult] = useState(null); + const [running, setRunning] = useState(false); + const [jobId, setJobId] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + apiGet("/factors") + .then((list) => { + setFactors(list); + // 默认示例组合:中期动量(重)+ 低波动(防) + setPicks([ + { name: "momentum_60", weight: 1.5 }, + { name: "volatility_60", weight: 1.0 }, + ]); + }) + .catch((e: Error) => setError(e.message)); + const { start: s, end: e } = recentRange(); + setStart(s); + setEnd(e); + }, []); + + function togglePick(name: string) { + setPicks((prev) => + prev.some((p) => p.name === name) + ? prev.filter((p) => p.name !== name) + : [...prev, { name, weight: 1 }], + ); + } + + function setWeight(name: string, weight: number) { + setPicks((prev) => prev.map((p) => (p.name === name ? { ...p, weight } : p))); + } + + async function run() { + const valid = picks.filter((p) => Number.isFinite(p.weight) && p.weight > 0); + if (valid.length === 0) { + setError("请选择至少一个因子并设置大于 0 的权重"); + return; + } + setRunning(true); + setError(""); + setJobId(""); + setResult(null); + try { + const spec: ResearchSpec = { + type: "backtest", + universe: { exclude_st: excludeSt, min_listing_days: 0 }, + factors: valid.map((p) => ({ name: p.name, weight: p.weight })), + selection: { top_n: topN }, + rebalance, + period: [start, end], + }; + const { job_id } = await submitJob(spec); + setJobId(job_id); + const out = await waitJob(job_id); + if (out.status === "success" && out.result) { + setResult(out.result); + } else { + setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); + } + } catch (e) { + setError((e as Error).message); + } finally { + setRunning(false); + setJobId(""); + } + } + + return ( + <> +

因子组合

+
+

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

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

选因子 + 设权重

+ + + + + + + + + + + + {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) => ( + + + + + ))} + +
月份收益
{m.year}-{String(m.month).padStart(2, "0")}= 0 ? "good" : "bad"}>{m.return_pct.toFixed(2)}%
+
+ )} + +
+

未建模约束(如实标注)

+
    {result.unimplemented.map((u, i) =>
  • {u}
  • )}
+
+ + ); +} diff --git a/frontend/web/app/factors/page.tsx b/frontend/web/app/factors/page.tsx index 58fd112..9c99e57 100644 --- a/frontend/web/app/factors/page.tsx +++ b/frontend/web/app/factors/page.tsx @@ -4,151 +4,269 @@ import { useEffect, useState } from "react"; import { apiGet } from "@/lib/api"; import { submitJob, waitJob } from "@/lib/jobs"; import type { FactorMeta, FactorTestReport, ResearchSpec } from "@/lib/types"; +import { recentRange } from "@/lib/dates"; export default function FactorsPage() { const [factors, setFactors] = useState([]); - const [name, setName] = useState("momentum_60"); - const [start, setStart] = useState("2024-03-01"); - const [end, setEnd] = useState("2024-12-31"); - const [report, setReport] = useState(null); + 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 [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null); const [jobId, setJobId] = useState(""); const [error, setError] = useState(""); useEffect(() => { apiGet("/factors") - .then(setFactors) + .then((list) => { + setFactors(list); + // 默认勾选两个代表性因子 + 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 日期不一致) + const { start: s, end: e } = recentRange(); + setStart(s); + setEnd(e); }, []); + function toggleCheck(name: string) { + setChecked((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + } + + function toggleExpand(name: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + } + async function run() { + const names = factors.map((f) => f.name).filter((n) => checked.has(n)); + if (names.length === 0) { + setError("请至少选择一个因子"); + return; + } setRunning(true); setError(""); setJobId(""); - setReport(null); + setReports({}); try { - const spec: ResearchSpec = { - type: "factor_test", - universe: { exclude_st: true, min_listing_days: 0 }, - factors: [{ name, weight: 1 }], - selection: { top_n: 10 }, - rebalance: "monthly", - period: [start, end], - }; - // 异步 Job:立即返回 job_id,后台执行后轮询取结果(全市场可能数十秒) - const { job_id } = await submitJob(spec); - setJobId(job_id); - const out = await waitJob(job_id); - if (out.status === "success" && out.result) { - setReport(out.result); - } else { - setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); + let index = 0; + for (const name of names) { + index += 1; + setCurrent({ idx: index, total: names.length, name }); + const spec: ResearchSpec = { + type: "factor_test", + universe: { exclude_st: true, min_listing_days: 0 }, + factors: [{ name, weight: 1 }], + selection: { top_n: 10 }, + rebalance: "monthly", + period: [start, end], + }; + const { job_id } = await submitJob(spec); + setJobId(job_id); + const out = await waitJob(job_id); + if (out.status === "success" && out.result) { + const report = out.result; + setReports((prev) => ({ ...prev, [name]: report })); + } else { + const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`; + setError((prev) => (prev ? `${prev}\n${msg}` : msg)); + } } } catch (e) { setError((e as Error).message); } finally { setRunning(false); + setCurrent(null); + setJobId(""); } } + const selectedCount = checked.size; + const doneCount = Object.keys(reports).length; + return ( <>

因子研究

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

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

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

- -
- {running && jobId &&
任务 {jobId} 后台执行中,请稍候…
} - {error &&
{error}
} - - {report && ( -
-
-
-
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}
-
-
- {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)}%
- )} + {running && ( +
+ 正在运行:{current?.name}({current?.idx}/{current?.total})—— 后台任务 {jobId},请稍候…
)} + {error &&
{error}
} +
+ + {Object.entries(reports).map(([name, report]) => { + const meta = factors.find((f) => f.name === name); + return ( +
+

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

+ +
+ ); + })} + + ); +} + +function FactorRow(props: { + factor: FactorMeta; + checked: boolean; + expanded: boolean; + onToggleCheck: () => void; + onToggleExpand: () => void; +}) { + const { factor: f } = props; + return ( + <> + + { e.stopPropagation(); props.onToggleCheck(); }}> + + + {f.name} + {f.lookback} + {f.direction === "higher_is_better" ? "高为好" : "低为好"} + {f.brief ?? f.description} + + {props.expanded && ( + + +
+ {f.name} · {f.description} +
+ 公式:{f.formula};回看 {f.lookback} 个交易日;频率 {f.frequency}; + 方向:{f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"} +
+
{f.brief}
+
+ + + )} + + ); +} + +function ReportView({ report }: { report: FactorTestReport }) { + 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}
+
+
+ {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 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。 + 单因子测试≠策略有效,需结合样本外与稳健性分析。
); diff --git a/frontend/web/app/layout.tsx b/frontend/web/app/layout.tsx index 6587c7f..59b7f67 100644 --- a/frontend/web/app/layout.tsx +++ b/frontend/web/app/layout.tsx @@ -16,6 +16,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) 总览 股票池 因子研究 + 因子组合 回测 实验 diff --git a/frontend/web/lib/dates.ts b/frontend/web/lib/dates.ts new file mode 100644 index 0000000..84ce2f4 --- /dev/null +++ b/frontend/web/lib/dates.ts @@ -0,0 +1,14 @@ +/** 研究区间默认值:结束=当日,开始=往前推 6 个月(YYYY-MM-DD)。 */ + +export function isoDate(d: Date): string { + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${d.getFullYear()}-${m}-${day}`; +} + +export function recentRange(): { start: string; end: string } { + const end = new Date(); + const start = new Date(); + start.setMonth(start.getMonth() - 6); + return { start: isoDate(start), end: isoDate(end) }; +} diff --git a/frontend/web/lib/types.ts b/frontend/web/lib/types.ts index e5c7b04..38c254a 100644 --- a/frontend/web/lib/types.ts +++ b/frontend/web/lib/types.ts @@ -13,6 +13,7 @@ export interface Stock { export interface FactorMeta { name: string; description: string; + brief?: string; formula: string; frequency: string; lookback: number;