Compare commits

..
12 Commits
Author SHA1 Message Date
Simon d53c4d3ca9 docs: AGENT §27.3 宽表约定(两端固定 + 按需提示 + 列宽实测) 2026-10-02 12:09:31 +08:00
Simon a395db892d 修复实验页右侧内容溢出:宽表固定两端 + 提示;对比表首列固定;月度表收紧内边距
- 实验列表 11 列在 1366px 及更窄窗口放不下,「操作」列被推到卡片外;macOS 覆盖式
  滚动条不滚动不显示,看起来就是「右侧内容溢出/被切掉」。现在左右两端固定
  (选择列、ID、操作列),列宽按实测单行内容宽度定,只有真溢出时才提示可横向滚动。
- 对比视图(指标对比 / 参数差异)列数随实验个数增长,首列改为固定,同样按需提示。
- 详情图层里的月度收益表 13 列超出 38px:收紧内边距并固定「年份」列。
- 可排序表头命中区 19.2px → 34px(与同排复选框等高),修掉 UI 规范门禁的 7 项失败。
- 顺手把对比区两处会原样显示的字面 ** 改成「」。

门禁:ruff 通过;pytest 524 passed;tsc 0 error;npm run build OK;test:charts 7 passed;
verify_ui_alignment.py 160 项通过 / 0 失败。
2026-10-01 19:33:33 +08:00
Simon e58367af27 feat(web): 因子研究/因子组合/股票筛选接入统一作业反馈;阶段圆点按作业类型区分
接着 /backtest 的那次改造,把其余会跑异步 Job 的页面也切到同一套
`useJobRunner` + `JobProgress`(用户原话:包括因子测试等所有测试都帮我完善用户反馈):

- **/factors**(一个因子一个 Job 的批量场景):删掉 `running/processed/current/jobId` 与
  按「已处理数 ÷ 总数」自算的 `Progress` **假百分比**;每轮把 `第 i/共 n 个` 交给反馈条,
  阶段/作业号/已用秒数全部来自后端。取消 = 用户明确意图 → 保留已出的报告、不再跑后续因子,
  不报错;单个因子失败仍继续跑其余因子,并把后端原文累积成**批级清单**(多因子时反馈条
  只能显示最后一个作业,前几个失败不能丢)。
- **/factors/compose**:删掉 `value={30}` 的假进度条;`archiveId` / 复用 `BacktestResultView` /
  「新页面放大」全部保留;failed/cancelled 交给反馈条,页面 error 只留参数与目录错误
  (同一失败不在两处各说一遍)。
- **/selection**:异步选股切反馈条;**同步**的「执行选股」保留原 loading(`POST /selections`
  没有 job_id,套上会去 `GET /jobs/{signal}` 撞 404)。
- **/signals**:`POST /api/signals` 是同步接口,**不套**作业反馈(不编作业号、不编阶段),
  改为点击即现的 `role="status"` 提示,如实写明「同步请求、请求期间不能关页、无阶段无取消、
  出错显示后端原文」。
- **阶段圆点按作业类型区分**(修掉一个真实缺陷):原来全站共用一张含 `queued/done` 的
  `STAGE_ORDER`,因子测试页实测出现过**裸英文** `factor_calculation` 且 4 个圆点全灰
  (`indexOf` = -1),还画出了因子测试根本不存在的「逐择股日选股 / 撮合与净值结算」。
  现在 `STAGE_PIPELINES = { factor_test: [加载→计算因子值→汇总], backtest: [加载→撮合→汇总],
  selection: [逐择股日选股] }`(阶段序列**不含 queued/done**,那是作业状态不是阶段),
  未知阶段显示「执行中(stage)」并保留已推进的圆点,不整排灰、不露裸枚举。

验证(真实浏览器 CDP,读数原文已记录):
- /factors:点击后 0.2s 内 `submitting`→`queued` + 作业号;+30s Pill「计算因子值」、
  圆点 `✓ 加载行情与因子数据 / ● 计算因子值 / ○ 汇总指标与曲线`(无「选股/撮合」、无英文枚举);
  成功态给「去对比 / 打开归档」。
- /selection:圆点只有 `逐择股日选股` 一段;取消 → 「已取消,没有归档」;
  另实测撞并发上限时如实显示后端原文「系统繁忙:并发研究任务已达上限」。
- /factors/compose:`submitting→queued→撮合与净值结算→success(EXP-…)`,业务结果与 5 处放大入口照旧。
- /backtest 回归:圆点由 4 个变 3 个(去掉后端**从不上报**的 selection 阶段),取消仍「已取消 + 没归档」。
- 自检产生的 12 个实验归档已全部删除(bulk-delete count:12,复查无残留)。
2026-10-01 18:37:25 +08:00
Simon c974415691 feat(web): 实验对比页支持批量删除 / 发起时间列 / 详情图层,打开归档改新页面
用户要求的三件事(原话):①批量删除;②增加「测试发起时间」;③点击详情用图层展示、
打开归档用新页面展示。

- **批量删除**(`POST /experiments/bulk-delete`,已在上一个提交实现接口):
  表头全选(只作用本页,工具条写明范围)+ 每行删除复选框,工具条显示「已选 N 个(待删除)
  | 删除所选 | 取消选择」;确认框写清**不可恢复**、结果只存归档这一份、关联作业记录会保留但
  读不回结果、想留底先导出 JSON。单次接口上限 200 个 id,超过前端分批;**分批中途失败时
  如实报「已删除 X 个,之后失败 —— 原文」**(前面几批是真删了,不能报成一个都没删);
  接口返回的 `missing` 单独用警示色报「M 个不存在(未计入删除数)」,不把「不存在」当删成功;
  只清掉确实删掉/本来不存在的选中项(没删成的留在选中里可直接重试),并同步清掉已删归档
  的详情图层 / 对比选择 / 勾选状态。
- **发起时间列**:`created_at` 用 `new Date()` 转**本地时间** `YYYY-MM-DD HH:mm:ss`
  (不直接截 UTC 字符串),空值显示「—」;表头可排序(空值恒排末尾、同秒用 id 兜底)。
- **详情图层**:新增通用 `components/Modal.tsx`(portal / `role="dialog" aria-modal` /
  `aria-labelledby` / 锁 body 滚动 / ESC 关闭 / 遮罩 mousedown 关闭而点内容区不关 /
  打开聚焦、关闭把焦点还给触发按钮 / Tab 焦点陷阱;样式走 CSS Module,不动 globals.css)。
  图层内复用 `ArchiveResultView`(按 kind 分发,回测的净值曲线、成交明细、买卖说明、
  因子曲线与「新页面放大」链接都在图层里可用),元信息区列出 id/类型/发起时间/版本/作业/区间/体积;
  标题栏含「在新页面打开完整归档」。
- **打开归档**:列表入口改 `target="_blank" rel="noreferrer"`(URL 不变)。

验证(真实浏览器 CDP,读数原文已记录):
- 表头 `发起时间 ↓`,首行 `EXP-8EA2819B / 2026-10-01 17:45:05`;点一次变升序(首行
  2026-09-06 17:17:37),再点回降序。
- 图层:`{aria-modal:true, 标题「EXP-8EA2819B 归档详情 · 回测」, canvas:35, tables:10,
  body.overflow:hidden}`;点内容区不关、遮罩关闭后焦点回到「详情」、ESC 关闭且恢复滚动。
- 批量删除:造 2 条一次性归档 → 勾选 → 确认「将永久删除 2 个归档」→ 横幅「已删除 2 个归档。」,
  行数 59→56;用 `/api/experiments` 逐条集合核对 `before-after=[]`、`after-before=[]`
  (只删勾选的);`missing` 分支实测「已删除 0 个归档,1 个不存在(未计入删除数)。」;
  进行中态实测「删除中… + disabled」。造出的 4 条自检归档已全部清理(404,关键词命中 0)。
- 打开归档用真实鼠标事件点击:页面目标 `/experiments` → `/experiments/EXP-10581FED`
  (合成 `a.click()` 会被 Chrome 弹窗策略拦掉,故用真实鼠标事件验证)。
- 另外把图层关闭按钮调大调亮(34×34、`--text-1`、图标 16px):2 倍放大截图逐字核对,
  原先「×」细到几乎看不见,现在与「在新页面打开完整归档」同高同线、清晰可见。
2026-10-01 18:31:04 +08:00
Simon 57d6082f91 fix: 如实说明买卖理由的覆盖边界(哪些不算买卖点)
`unimplemented` 与「买卖说明」里都没写清一件事:理由覆盖的是 **signal_history 里的
每个买卖点(含涨停/停牌/跌停/现金不足等未成交情形)**,而「当日排名在 TopN 之外、
策略本来就无意买入」的候选根本不算买卖点 —— 用户看不到某只票的买入理由时,
应该能立刻分清「是漏了记录」还是「策略本来就没打算买」。

- 组合引擎与单策略引擎的 `_unimplemented` 各加一条说明,指向 `selection_history`
  可查完整候选与名次(AGENT.md §24:没实现/有边界的要显式写出)。
- 「买卖说明」卡片底部同步写出这条边界。
2026-10-01 18:15:26 +08:00
Simon a3055eff5d docs(agent): 新增「买卖理由词表」与「长任务反馈」两条硬约束(§27.1 / §27.2)
把这两次踩过的坑固化成约束,避免后续 agent 各自发挥:
- §27.1:买卖点必须带结构化理由(成交与未成交都要),原因分类封闭词表、两个引擎共用;
  data 只放引擎当时的真实数字,前端不推算;跌出 TopN / 不在候选池 / 全量换仓 / Tmin /
  Tmax / 涨跌停 / 现金不足必须区分,宁可新增 code 也不套语义不符的旧 code;
  因子曲线口径(持仓市值加权原始值、空仓不落点)+ 方向/单位必须写明;
  每条曲线可新页面放大且放大页从归档读。
- §27.2:长任务点了立刻有字、显示真实作业号/阶段/逐秒已用、可取消、失败给后端原文、
  成功给归档入口、自动滚入视野;同步接口没有作业号时如实说明,禁止伪造。
2026-10-01 18:13:37 +08:00
Simon 2a88ca6076 fix(web): 去掉界面文案里的字面 **(hint 不走 Markdown)
反馈条与因子曲线的口径说明里写了 `**加粗**`,但这些位置是纯文本 hint,页面会把星号
原样显示出来(放大页截图逐字核对时发现)。改为中文引号「」,语义不变、不再露出 Markdown 语法。
2026-10-01 18:13:00 +08:00
Simon bb48c91853 docs: 同步「买卖理由 / 因子曲线 / 曲线放大 / 作业反馈」与门禁条数
- §6.3 新增两块说明:①回测结果的买卖理由(封闭词表、成交与未成交都覆盖、
  区分跌出 TopN / 不在候选池 / 全量换仓 / Tmin / Tmax / 涨跌停 / 现金不足)、
  因子曲线口径(持仓市值加权原始值、空仓不落点、带方向与单位)、
  `/charts/{id}?s=...` 放大页(Server Component,数据从归档直出,未归档不给假按钮);
  ②全站统一的作业反馈(useJobRunner + JobProgress:提交瞬间有字、已用每秒自增、
  可取消、失败给后端原文、成功给归档入口、自动滚入视野)。
- 门禁条数更新为 524;回测结果契约脚本补充「理由词表/名次/因子值/因子曲线」断言说明;
  列出新增的两个理由测试文件。
2026-10-01 18:10:29 +08:00
Simon e8fa67ad40 feat(web): 回测页统一作业反馈条(点了立刻有字 / 真实阶段 / 可取消)
用户反馈:「点击回测没有任何反馈,不清楚是不是已经开始回测」。

- 新增全站统一的 `useJobRunner`(`lib/jobs.ts`)与 `JobProgress` 组件:
  提交瞬间即显示「排队中 + 作业号 + 发起时间」,已用时间**每秒自增**(不依赖后端
  是否报新阶段),阶段来自后端真实 `stage`,排队/运行中可**取消任务**、失败直接显示
  后端原文、成功给「打开归档 / 去对比」。反馈条出现时自动滚入视野(按钮在长表单底部,
  不滚过去就等于没显示);`role="status" aria-live="polite"`。
- `/backtest` 改用它:删掉原先手写的阶段条与 stage/elapsed/jobId 三个 state,
  取消走 `POST /jobs/{id}/cancel`,取消后如实提示「已取消,没有归档」而不是报错。
- 顺带修掉「未命名组合点运行 → 撞 422」:运行是**临时组合不落库**,未填名字时按
  「未命名组合(仅本次运行)」提交并明确提示,不再把后端 422 原文甩给用户。
- 修掉反馈条里 `**加粗**` 字面残留(hint 不走 Markdown)。

验证(真实浏览器 CDP,实测数字):
- 勾选策略 → 点「运行回测组合」→ **+0.11s** 反馈条已显示 `排队中` + `JOB-8508F84D`
  + 发起于 18:05:28 + 已用 0s + 四个真实阶段 + 「取消任务」按钮;+4.5s 进入
  `加载行情与因子数据`;点取消 → 「正在取消…」→「已取消」并提示没有归档。
- 反馈条截图逐字核对(阶段、作业号、时间、按钮),并据此修掉 markdown 残留。
2026-10-01 18:08:58 +08:00
Simon 7e369d9680 feat(quant): 单策略引擎也给出买卖理由与因子曲线(口径与组合引擎一致)
「因子组合」/`POST /api/research/backtests` 走的是 `LocalEngine/TopKBacktestRunner`,
上一版只把理由接进了组合引擎,同一件事在两个引擎上就会有两种说法。这次补齐:

- `engine.py` / `qlib_adapter/engine.py`:因子面板**只算一次**
  (`build_factor_panels_full`)→ 复合分与「理由里引用的因子原始值」同源同张面板;
  复合分口径逐字未变(与 `selection.score_panel_for_factors` 相同)。
- `local_engine.py`:调仓日保留完整排名与合格集,各站点写入结构化理由 ——
  买入(按名次建仓 / 顺延成交 / 涨停 / 停牌 / 现金不足 / 不足最低佣金)、
  卖出(全量换仓 / 跌出 TopN / 不在候选池 / 停牌顺延 / 跌停顺延);
  `Trade.entry_reason/exit_reason` 两端齐全;每个交易日记录持仓市值,
  结果填 `factor_curves`(持仓市值加权平均的因子原始值,空仓日不落点)。
- 新增 `SELL_REBALANCE_FULL`(「调仓换仓卖出」):单策略调仓是「先全清再建仓」,
  被卖出的股票**可能仍排在 TopN 内**(如 rank=1),这时写「跌出 TopN」就是假解释;
  按事实分 code(仍在 TopN 内 → 全量换仓;否则 → 跌出 TopN / 不在候选池)。
- 顺延成交不拿挂单日的旧名次冒充当日名次(rank/total/score=None,因子值/成交价/预算
  取成交当日真实值);「候选池不足」的提示记录保持 reason=None(词表里没有对应语义,
  硬套就是编理由)。

验证:
- 新增 `tests/test_local_engine_reasons.py` 14 条:理由数字对回面板、涨停比值对回行情与
  板块规则、停牌/跌停/现金不足/最低佣金、顺延成交、全量换仓 vs 不在池两个分支、
  Trade 两端理由、因子曲线市值加权(手算加权值断言 + 等权平均对不上)、空仓不落点。
  后端 524 条全过(510 + 14),ruff clean。
- 强回归:用改前引擎并排跑 9 个场景,`signal_history`(日期/方向/成交/原因文案/价格)、
  `trades`、`positions`、`summary`、净值/回撤、`unimplemented` 逐条一致 —— 理由与曲线
  是纯新增字段,成交行为零变化。
2026-10-01 18:08:42 +08:00
Simon 633176a3d1 feat(api): 实验归档支持批量删除(含缺失项如实回报)
「实验对比」页需要批量删除,逐个 DELETE 会有 N 次往返且中途失败会留下半删除状态。

- `POST /experiments/bulk-delete`:一次最多 200 个 id(`BULK_DELETE_MAX_IDS`),
  按请求顺序去重;返回 `deleted` / `missing` / `count`,**存在的删掉、不存在的如实列出**,
  不假装全部成功(前端据此提示「N 个已删、M 个不存在」)。
- 路由声明在 `GET /{experiment_id}` 之前,避免被路径参数吞掉。
- 测试 +2:删除与缺失混合场景、id 列表校验(空/超长)。
2026-10-01 17:57:04 +08:00
Simon 48a97c2a12 feat(backtest): 买卖点理由(用数据说话)+ 因子曲线 + 曲线新页面放大
用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。

一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `quant/trade_reasons.py`:封闭词表 + 文案构造器,组合引擎与单策略引擎共用,
  避免两个引擎对同一件事写出两种说法。理由里带**引擎当时的真实数字**:
  综合分名次/候选数/综合分/各因子原始值/持有交易日/预算与最低佣金/涨停比值等。
- 买入:按名次建仓、顺延成交、涨停未买、停牌未买、现金不足、不足最低佣金;
  卖出:跌出 TopN(含第几名掉出)、被股票池过滤(与「跌出 TopN」分开写)、
  超 Tmax 强制了结、Tmin 保护暂留、停牌/跌停顺延。
- `ActionRecord.reason` 覆盖**成交与未成交**全部买卖点(原 `reject_reason` 保留不动,
  老归档仍可读);`Trade.entry_reason / exit_reason` 跟着成交记录走。
- 名次来自调仓日完整排名(新增 `_ranked_by_day`),拿不到名次时如实写「未给出名次」,
  绝不编造一个名次填进去。
- 未成交明细不再只写执行层原因:把「为什么选中它、当时各因子多少」一并给出。

二、因子曲线
- `FactorCurve`:每个策略因子一条曲线,值为**当日持仓按市值加权平均的原始值**
  (不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),并带
  label/direction/unit 供界面说明口径;`FactorDef/FactorTemplate` 新增 `unit`
  (股息率 %、量比/接近新高 倍数、动量等 小数),11 个内置因子实例已逐一核对。
- 归档体积预算照旧按整包计量,无需改迁移。

三、界面
- 结果页新增「买卖说明」区块:全部买卖点 + 理由 + 数字标签,支持方向/成交状态/关键字
  筛选与日期排序;成交明细表加「为什么买 / 为什么卖」两列;新增「因子曲线」区块,
  每条曲线标出组合成交日,直接对照「买卖发生在什么水平」。
- 「新页面放大」:每条曲线(净值/回撤/因子/个股/月度)都能开 `/charts/{归档id}?s=...`
  整页看大图;放大页是 Server Component,数据从归档直出,URL 可分享且与归档一致。
  未归档的结果如实说明「未归档,无法放大」,不给坏链接。
- 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双):修掉 0.03125 在理由原文里
  显示 0.0312、旁边标签显示 0.0313 的不一致(17 组边界值与 Python 逐一比对一致)。
- `/factors/compose` 结果区改用同一个 `BacktestResultView`,两处口径不会再漂移。

验证:
- 新增 `tests/test_trade_reasons.py` 8 条(买入数字、跌出 TopN 名次、不在候选池、
  Tmax、Tmin 暂留、涨停未成交、因子曲线加权值、空仓不落点);后端 510 条全过,ruff clean。
- 真实数据端到端:`/api/combos/run` 6 个月高股息组合(EXP-8EA2819B)13 个买卖点
  100% 带理由与数字,因子曲线 dividend_yield 117 点、单位 %;
  `scripts/verify_backtest_page_contract.py`(4 年、301 个买卖点、140 笔成交)扩展断言
  理由词表/名次/因子值/曲线单调性后通过。
- 浏览器实测:归档详情页与放大页 `/charts/...?s=factor:dividend_yield` 等 5 种曲线
  全部 200 渲染,截图确认表格与曲线数值正确。
2026-10-01 17:57:00 +08:00
34 changed files with 4693 additions and 468 deletions
+48
View File
@@ -817,6 +817,54 @@ factor_exposure
前端组件只依赖这个结构。
## 27.1 买卖理由:必须能回答「为什么买 / 为什么卖」,且数字来自引擎
用户看回测结果时的第一个问题不是「赚了多少」,而是「**为什么在这里买 / 卖**」。
因此每个买卖点(**成交的与未成交的都要**)必须带结构化理由
(`TradeReason{code, text, data}`,见 `backend/app/quant/trade_reasons.py`):
- **原因分类是封闭词表**:组合引擎(`combo_engine.py`)与单策略引擎(`local_engine.py`)
共用同一套 code 与文案构造器,禁止各自手写措辞 —— 否则同一件事会出现两种说法。
- **`data` 里只能是引擎当时的真实数字**(名次 / 候选数 / 综合分 / 各因子**原始值** /
持有交易日 / 预算 / 涨停比值…)。前端**只展示不推算**;拿不到名次就写「未给出名次」,
不拿旧名次或其他日期的数据冒充。
- **把事实说准**:跌出 TopN ≠ 不在候选池(被股票池/条件过滤)≠ 全量换仓
(策略每次调仓先清仓,被卖的股票可能仍排在前列)≠ Tmin 保护暂留 ≠ 超 Tmax 强制了结
≠ 涨停/跌停/停牌/现金不足。宁可为一种情况新增一个 code,也不要套一个语义不符的旧 code。
- **因子曲线**(`result.factor_curves`)= 当日**持仓按市值加权平均的原始值**
(不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),
界面必须同时写出方向与单位(如「股息率 %,越高越好」),否则读者会误判曲线的含义。
- **每条曲线都要能新页面放大**(`/charts/{归档id}?s=...`):放大页从**归档**读同一份数据
(URL 可分享、口径不漂移);没有归档 id 时如实说明「未归档,无法放大」,不给坏链接。
## 27.2 长任务反馈:点了立刻有字、看得出在动、出事了能自救
用户原话:「点了回测没有任何反馈,不清楚是不是已经开始」。因此跑异步 Job 的页面必须:
- 提交**瞬间**就有反馈(提交中 → 排队中),不等到后端返回;
- 显示**真实**作业号 / 阶段 / 逐秒自增的已用时间(用 `lib/jobs.ts` 的 `useJobRunner`
+ `components/JobProgress.tsx`,不要各页手写一套);
- 排队/运行中可**取消**;失败显示后端原文;成功给「打开归档 / 去对比」入口;
- 反馈条出现时**自动滚入视野**,并带 `role="status" aria-live="polite"`;
- 同步接口(如 `POST /api/signals`)**没有**作业号与阶段时,如实说明「同步请求、无阶段、
不可取消」,**禁止**合成假作业号或假进度喂给反馈组件。
## 27.3 宽表:两端固定 + 按需提示,绝不"右侧被切掉"
用户原话:「experiments 页面右侧内容溢出了」。实测原因不是整页溢出,而是**卡片内的横向滚动**:
在 macOS 上覆盖式滚动条不滚动就不显示,于是看起来就是内容被切掉、也没有滚动条可拉。因此宽表:
- 列数 ≥ 9 或列宽会随数据增长的表(实验列表 `.tbl--wide`、对比表 `.tbl--pin-first`、
月度收益表 `.tbl--monthly`)必须**固定首列**;操作列在右端时用 `.tbl--wide` 固定右端,
保证任何窗口宽度下「看的是哪一行」和「能点哪里」都在视野内;
- 固定列必须有不透明底色(卡片是渐变,取近似纯色)并覆盖 `:hover` / `.row-active`,
否则中间列会从下面透出来、整行高亮在两端断开;
- 列宽按**实测单行内容宽度**定(用 CDP 量 `scrollWidth` 与单元格内容宽度),不要凭感觉写,
并且**不要在 JSX 里写行内 `width`**——它会盖掉 CSS(曾让「操作」列多占 88px,整表放不下);
- 「可横向滚动」提示只在实测 `scrollWidth > clientWidth` 时出现(`useTableScrollHint`),
窗口够宽时不显示废话;
- 门禁:`python3 scripts/verify_ui_alignment.py`(160 项)必须 0 失败,含 375px 与 1500px 两档。
---
# 28. AI Agent 约束
+39 -1
View File
@@ -1,4 +1,4 @@
"""Experiment API(Phase 4):列表 / 详情 / 删除 / 一键复跑。"""
"""Experiment API(Phase 4):列表 / 详情 / 删除(单个 + 批量)/ 一键复跑。"""
from __future__ import annotations
@@ -7,6 +7,7 @@ from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response
from pydantic import BaseModel, Field
from app.api.deps import DbSession, ExperimentRepoDep, JobRepoDep
from app.application.services.job_executor import new_id, run_job_background
@@ -25,6 +26,20 @@ router = APIRouter(prefix="/experiments", tags=["experiments"])
LIST_DEFAULT_LIMIT = 200
LIST_MAX_LIMIT = 1000
# 单次批量删除的 id 上限:够覆盖列表一页(默认 200 条),又不至于让一次请求
# 把整张表拖进内存。超了直接 422(附上限值),不静默截断成前 N 个。
BULK_DELETE_MAX_IDS = 200
class BulkDeleteRequest(BaseModel):
"""批量删除请求体(**必填** id 列表,1~200 个)。"""
ids: list[str] = Field(
min_length=1,
max_length=BULK_DELETE_MAX_IDS,
description=f"要删除的归档 id,1~{BULK_DELETE_MAX_IDS} 个(重复 id 自动去重)",
)
def _experiment_meta(exp: ExperimentRecord | ExperimentSummary) -> dict:
"""列表项视图(body 形状与旧版一致,仅**新增** data_version / job_id / result_bytes)。
@@ -85,6 +100,29 @@ def list_experiments(
return [_experiment_meta(e) for e in rows]
@router.post("/bulk-delete", summary=f"批量删除归档(1~{BULK_DELETE_MAX_IDS} 个,逐个回报)")
def bulk_delete_experiments(
body: BulkDeleteRequest,
session: DbSession,
experiment_repo: ExperimentRepoDep,
) -> dict:
"""批量删除归档,返回 `{"deleted": [...], "missing": [...], "count": n}`。
语义与单个删除完全一致(只删 experiment 行,job 历史保留),另外:
- **重复 id 先按出现顺序去重**(同一个 id 报两次没有意义,也不该算两次成功);
- **不静默跳过**:库里没有的 id 单独放进 `missing`,让界面能如实说
「删了 3 个,2 个没找到(可能已被别处删掉)」——把缺失当成功会更难排查;
- 一次请求内的 id 上限 `BULK_DELETE_MAX_IDS`,超了 Pydantic 直接 422 并带上限值。
"""
ids = list(dict.fromkeys(body.ids))
deleted: list[str] = []
missing: list[str] = []
for experiment_id in ids:
(deleted if experiment_repo.delete(experiment_id) else missing).append(experiment_id)
session.commit()
return {"deleted": deleted, "missing": missing, "count": len(deleted)}
@router.get("/{experiment_id}", summary="Experiment 详情(含完整结果)")
def get_experiment(experiment_id: str, experiment_repo: ExperimentRepoDep) -> dict:
exp = experiment_repo.get(experiment_id)
+57
View File
@@ -285,6 +285,24 @@ class BacktestSummary(BaseModel):
benchmark_return_pct: float | None = None
class TradeReason(BaseModel):
"""一次交易意图 / 成交的**结构化理由**:用当时的真实数字解释「为什么买 / 为什么卖」。
为什么不让前端自己推:界面上出现的每个数字(排名、综合分、因子值、持有天数)
都必须来自引擎当时的计算,否则就是「看着像真的」。理由因此分三层:
- `code`:机器可判定的原因分类(封闭取值,见 `quant/trade_reasons.py`)。
前端据此筛选 / 上色,不去解析文案;
- `text`:给人读的一句话(自带关键数字,可单独展示);
- `data`:当时真实数值(`rank` / `total` / `score` / `top_n` / `hold_days` /
`factors`(各因子当时的原始值)/ `budget` …)。前端只展示,不推算。
"""
code: str
text: str
data: dict = Field(default_factory=dict)
class Trade(BaseModel):
entry_date: date
exit_date: date
@@ -293,6 +311,12 @@ class Trade(BaseModel):
entry_price: float
exit_price: float
return_pct: float
entry_reason: TradeReason | None = Field(
default=None, description="买入理由(建仓当日引擎给出的结构化理由)"
)
exit_reason: TradeReason | None = Field(
default=None, description="卖出理由(了结当日引擎给出的结构化理由)"
)
class Position(BaseModel):
@@ -318,6 +342,10 @@ class ActionRecord(BaseModel):
signal=BUY/SELL(策略意图);filled=是否实际成交;reject_reason 给出未成交原因
(涨停/跌停/无价/现金不足等)。fills = [a for a in signal_history if a.filled]。
`reason` 是**数据化**的为什么:`reject_reason` 只说「没成交」(执行层),
`reason` 同时覆盖成交与未成交(策略层 + 执行层),并带上当时的排名 / 综合分 /
各因子原始值,前端「买卖说明」直接用,不再二次推断。
`name` 为展示增强字段:由服务层按股票池统一回填(未命中则为 None),
引擎自身不感知名称 —— 引擎只处理 symbol,保持纯行情计算职责。
"""
@@ -329,6 +357,9 @@ class ActionRecord(BaseModel):
filled: bool
reject_reason: str | None = None
price: float | None = Field(default=None, description="成交价(fill)或意图参考价")
reason: TradeReason | None = Field(
default=None, description="结构化理由(成交与未成交都有;旧归档为 null)"
)
class SymbolCurve(BaseModel):
@@ -352,6 +383,25 @@ class SymbolCurve(BaseModel):
)
class FactorCurve(BaseModel):
"""单个因子在回测期内的时间序列(**持仓组合加权平均原始值**)。
口径必须写死,否则读图会读反:
- 值为该因子在**当日持仓股票**上的权重加权平均(权重 = 该股当日市值 / 组合权益),
是**原始值**:不做 z-score、不按方向取负 —— 图上看到的就是因子本身;
- 空仓日不落点(不插值、不用 0 假填充),曲线中间会出现空档;
- `direction` 一并归档:低为好的因子,曲线升高不等于「更好」;
- `unit` 是代码注册表里的事实(`%` / `倍数` / `小数`),用于坐标轴与提示文案。
"""
name: str
label: str
direction: str = "higher_is_better"
unit: str | None = None
points: list[CurvePoint] = Field(default_factory=list)
class BacktestResult(BaseModel):
"""标准化回测结果(ARCHITECTURE §14)。前端只依赖该结构。"""
@@ -375,6 +425,13 @@ class BacktestResult(BaseModel):
default_factory=list,
description="个股收益率曲线 + 买卖点标注(按期末收益绝对值降序,体积可控)",
)
factor_curves: list[FactorCurve] = Field(
default_factory=list,
description=(
"策略用到的每个因子的时间序列(持仓加权平均原始值):"
"用来解释「买卖依据的那个因子在各时点是什么水平」"
),
)
turnover_pct: float
unimplemented: list[str] = Field(
default_factory=list,
+171 -23
View File
@@ -45,8 +45,26 @@ from app.domain.entities.research import (
UniverseSpec,
YearlyReturn,
)
from app.quant.composite import build_score_panel
from app.quant.composite import build_factor_panels_full, composite_score
from app.quant.factors import FactorDef
from app.quant.local_engine import _limit_up_ratio, _nan, rebalance_dates
from app.quant.trade_reasons import (
BUY_SKIP_HALTED,
BUY_SKIP_LIMIT_UP,
BUY_SKIP_MIN_COMMISSION,
BUY_SKIP_NO_CASH,
SELL_DEFER_HALTED,
SELL_DEFER_LIMIT_DOWN,
SELL_DEFER_TMIN,
SELL_DROP_TOPN,
SELL_FORCE_TMAX,
build_factor_curves,
buy_filled,
buy_skipped,
factor_values,
sell_deferred,
sell_filled,
)
TRADING_DAYS = 252
@@ -84,18 +102,26 @@ def combine_strategy_scores(
daily: pd.DataFrame,
strategies: list[SelectionStrategyRef],
eligibility_fns: list,
) -> tuple[pd.DataFrame, object]:
"""多策略 → (综合分面板, 合并合格集闭包)。
) -> tuple[pd.DataFrame, object, dict[str, tuple[FactorDef, pd.DataFrame]]]:
"""多策略 → (综合分面板, 合并合格集闭包, 原始因子面板)。
综合分面板 index=trade_date, columns=symbol,值为 Borda 秩和(越大越优先)。
合并合格集闭包 `combined(as_of) -> set[symbol] | None`:各策略合格集的并集;
全部策略都不过滤时返回 None(= 不过滤,交给面板的 dropna 处理)。
第三个返回值是「策略用到的每个因子的**原始**面板」(key = 因子键):
复合分是 z-score 后的无量纲分,解释不了「股息率到底几厘」,因此买卖理由与
因子曲线必须回到原始值。同一因子被多个策略引用时只算一次。
"""
# 每个策略一张「复合 zscore 面板」(已按方向加权求和)
panels: list[pd.DataFrame] = []
raw_panels: dict[str, tuple[FactorDef, pd.DataFrame]] = {}
for ref in strategies:
_universe, factors, _conditions = _ref_to_specs(ref)
panels.append(build_score_panel(daily, factors))
full = build_factor_panels_full(daily, factors)
for defn, panel, _weight in full:
raw_panels.setdefault(defn.name, (defn, panel))
panels.append(composite_score([(d.name, p, w, d.direction) for d, p, w in full]))
borda = borda_combine(panels)
@@ -117,7 +143,7 @@ def combine_strategy_scores(
out |= s
return out
return borda, combined
return borda, combined, raw_panels
# ---------- 持仓区间回测 runner ----------
@@ -129,6 +155,9 @@ class _Holding:
entry_date: date
entry_price: float
entry_ts: object = None # pd.Timestamp:按「交易日」计持仓天数用(自然日会跨周末失真)
# 建仓理由(结构化):了结时原样写进 Trade.entry_reason,保证「为什么买」在
# 成交明细里能一路带出来 —— 持仓中途没有别的机会把它丢掉。
entry_reason: object = None
_UNIMPLEMENTED_BASE = [
@@ -144,6 +173,11 @@ _UNIMPLEMENTED_BASE = [
"多策略打分采用 Borda 秩和(各策略 1/名次 求和):不假设不同策略的因子分值可比,"
"但极端情况下某策略覆盖极少股票会使其秩和贡献偏大"
),
(
"买卖说明覆盖 signal_history 里的每个买卖点(含涨停/停牌/现金不足等未成交情形);"
"但「当日排名在 TopN 之外、策略本来就无意买入」的候选不计为买卖点,"
"要看完整候选与名次请查选股明细(selection_history)"
),
]
@@ -158,6 +192,7 @@ class HoldingBandRunner:
score: pd.DataFrame,
close: pd.DataFrame,
eligibility_fn=None,
factor_panels: dict[str, tuple[FactorDef, pd.DataFrame]] | None = None,
) -> None:
self.combo = combo
self.costs = costs
@@ -166,11 +201,19 @@ class HoldingBandRunner:
self.close = close.sort_index()
self.score = score.reindex(self.close.index).sort_index()
self.eligibility_fn = eligibility_fn
# 策略用到的因子原始面板:买卖理由里的因子值、以及因子曲线都从这里取
self.factor_panels = factor_panels or {}
self.selection_history: list[RankedPick] = []
self.signal_history: list[ActionRecord] = []
self.traded_symbols: list[str] = []
self._traded: set[str] = set()
self._no_prev_close: set[str] = set()
# 调仓日的完整排名与合格集:卖出理由要能说出「第几名掉出去的」,
# 以及「是掉出 TopN 还是根本不在候选池(被股票池/条件过滤)」
self._ranked_by_day: dict[pd.Timestamp, pd.Series] = {}
self._elig_by_day: dict[pd.Timestamp, set[str] | None] = {}
# 每个交易日的持仓市值权重(因子曲线用;空仓日空 dict → 不落点)
self._weights_by_day: dict[pd.Timestamp, dict[str, float]] = {}
# ---- 主循环 ----
@@ -226,6 +269,7 @@ class HoldingBandRunner:
equity_rows[d] = _equity(d)
self._mark_curve(d, holdings, cum, curve_rows)
self._weights_by_day[d] = self._holding_weights(d, holdings)
equity = pd.Series(equity_rows).sort_index()
return self._to_result(equity, trades, positions, notional, cum, curve_rows)
@@ -241,6 +285,9 @@ class HoldingBandRunner:
if elig is not None:
score_d = score_d[score_d.index.isin(elig)]
ranked = score_d.sort_values(ascending=False)
# 完整排名留下来:卖出理由要说「第几名掉出去的」,只有 TopN 说不出这个数
self._ranked_by_day[d] = ranked
self._elig_by_day[d] = set(elig) if elig is not None else None
top = ranked.head(n).index.tolist()
day = d.date()
for r, sym in enumerate(top, start=1):
@@ -249,6 +296,50 @@ class HoldingBandRunner:
)
return top
def _rank_of(self, d: pd.Timestamp, symbol: str) -> dict:
"""该股在 `d` 日的排名上下文:rank / total / score / in_pool。
卖出理由必须能区分三件事:**在池但排名掉出去**、**已被股票池/条件过滤**
(如转为 ST)、**当日没有分数**(数据缺失)。都写成「跌出 TopN」会掩盖真相。
"""
out: dict = {"rank": None, "total": None, "score": None, "in_pool": None}
ranked = self._ranked_by_day.get(d)
if ranked is None:
return out # 非调仓日(如 Tmax 强制了结发生在普通交易日):没有当日排名
elig = self._elig_by_day.get(d)
out["total"] = int(len(ranked))
out["in_pool"] = True if elig is None else (symbol in elig)
if symbol in ranked.index:
loc = ranked.index.get_loc(symbol)
if isinstance(loc, int):
out["rank"] = loc + 1
out["score"] = round(float(ranked.loc[symbol]), 6)
return out
def _holding_weights(self, d: pd.Timestamp, holdings) -> dict[str, float]:
"""当日持仓市值权重(因子曲线用)。取不到价的持仓不参与,空仓日返回空 dict。"""
out: dict[str, float] = {}
for s, h in holdings.items():
if h.qty <= 0 or s not in self.close.columns:
continue
px = self.close.at[d, s]
if _nan(px) or px <= 0:
continue
out[s] = float(h.qty) * float(px)
return out
def _reason_ctx(self, d: pd.Timestamp, symbol: str, *, top_n: int | None) -> dict:
"""构造理由所需的公共上下文(排名 + 各因子当时的原始值)。"""
ctx = self._rank_of(d, symbol)
return {
"rank": ctx["rank"],
"total": ctx["total"],
"top_n": top_n,
"score": ctx["score"],
"factors": factor_values(self.factor_panels, d, symbol) or None,
"not_in_pool": ctx["in_pool"] is False,
}
# ---- Tmax 强制了结(每日) ----
def _force_exit_over_max(self, d, day, holdings, cash, trades, tmax) -> float:
@@ -261,19 +352,33 @@ class HoldingBandRunner:
continue
c = close_d.get(s)
p = prev_d.get(s) if prev_d is not None else None
ctx = self._reason_ctx(d, s, top_n=None)
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason=f"持有 {held} 天超 Tmax={tmax},但当日无行情,顺延")
reject_reason=f"持有 {held} 天超 Tmax={tmax},但当日无行情,顺延",
reason=sell_deferred(SELL_DEFER_HALTED, cause="halted",
hold_days=held, tmax=tmax, **ctx))
)
continue
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason=f"持有 {held} 天超 Tmax={tmax},但跌停无法卖出,顺延")
reject_reason=f"持有 {held} 天超 Tmax={tmax},但跌停无法卖出,顺延",
reason=sell_deferred(SELL_DEFER_LIMIT_DOWN, cause="limit_down",
hold_days=held, tmax=tmax,
close=float(c), prev_close=float(p),
limit_ratio=1.0 - (_limit_up_ratio(s) - 1.0),
**ctx))
)
continue
cash = self._sell(s, h, float(c), day, cash, trades, holdings)
cash = self._sell(
s, h, float(c), day, cash, trades, holdings,
reason=sell_filled(code=SELL_FORCE_TMAX, rank=None, total=ctx["total"],
top_n=None, score=ctx["score"], factors=ctx["factors"],
hold_days=held, tmax=tmax, price=float(c),
return_pct=(float(c) / h.entry_price - 1.0) * 100),
)
return cash
# ---- 调仓日:增量调向目标 ----
@@ -289,10 +394,13 @@ class HoldingBandRunner:
continue
h = holdings[s]
held = self._held_trading_days(h.entry_ts, d)
ctx = self._reason_ctx(d, s, top_n=n)
if held < tmin:
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason=f"掉出 TopN 但仅持 {held} 天 < Tmin={tmin},暂留")
reject_reason=f"掉出 TopN 但仅持 {held} 天 < Tmin={tmin},暂留",
reason=sell_deferred(SELL_DEFER_TMIN, cause="tmin", hold_days=held,
tmin=tmin, **ctx))
)
continue
c = close_d.get(s)
@@ -300,16 +408,30 @@ class HoldingBandRunner:
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="掉出 TopN,但当日无行情,保留到下一调仓")
reject_reason="掉出 TopN,但当日无行情,保留到下一调仓",
reason=sell_deferred(SELL_DEFER_HALTED, cause="halted",
hold_days=held, tmin=tmin, **ctx))
)
continue
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="掉出 TopN,但跌停无法卖出,保留到下一调仓")
reject_reason="掉出 TopN,但跌停无法卖出,保留到下一调仓",
reason=sell_deferred(SELL_DEFER_LIMIT_DOWN, cause="limit_down",
hold_days=held, tmin=tmin,
close=float(c), prev_close=float(p),
limit_ratio=1.0 - (_limit_up_ratio(s) - 1.0),
**ctx))
)
continue
cash = self._sell(s, h, float(c), day, cash, trades, holdings)
cash = self._sell(
s, h, float(c), day, cash, trades, holdings,
reason=sell_filled(code=SELL_DROP_TOPN, rank=ctx["rank"], total=ctx["total"],
top_n=n, score=ctx["score"], factors=ctx["factors"],
hold_days=held, tmin=tmin, price=float(c),
return_pct=(float(c) / h.entry_price - 1.0) * 100,
not_in_pool=ctx["not_in_pool"]),
)
# b) 补买:从 TopN 里挑尚未持有的,按等权目标用可用现金买入,直到 N 只或现金耗尽
current = [s for s in topn if s in holdings and holdings[s].qty > 0]
@@ -332,30 +454,45 @@ class HoldingBandRunner:
for s in buys:
c = close_d.get(s)
p = prev_d.get(s) if prev_d is not None else None
ctx = self._reason_ctx(d, s, top_n=n)
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
reject_reason="无行情(停牌),无法买入")
reject_reason="无行情(停牌),无法买入",
reason=buy_skipped(BUY_SKIP_HALTED, **ctx))
)
continue
if not _nan(p) and p > 0 and c / p >= _limit_up_ratio(s):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
reject_reason="涨停,无法追买")
reject_reason="涨停,无法追买",
reason=buy_skipped(BUY_SKIP_LIMIT_UP, close=float(c),
prev_close=float(p),
limit_ratio=_limit_up_ratio(s), **ctx))
)
continue
budget = min(per_budget, cash)
if budget <= 1e-9:
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
reject_reason="可用现金不足,未成交")
reject_reason="可用现金不足,未成交",
reason=buy_skipped(BUY_SKIP_NO_CASH, budget=budget, **ctx))
)
continue
ok, spent = self._buy(s, budget, d, float(c), day, holdings, notional)
buy_reason = buy_filled(
rank=ctx["rank"], total=ctx["total"], top_n=n, score=ctx["score"],
factors=ctx["factors"], price=float(c) * (1 + self.costs.slippage_rate),
budget=budget,
)
ok, spent = self._buy(s, budget, d, float(c), day, holdings, notional,
reason=buy_reason)
if not ok:
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
reject_reason="预算不足以覆盖最低佣金,未成交")
reject_reason="预算不足以覆盖最低佣金,未成交",
reason=buy_skipped(BUY_SKIP_MIN_COMMISSION, budget=budget,
min_commission=self.costs.min_commission,
**ctx))
)
continue
cash -= spent
@@ -365,25 +502,29 @@ class HoldingBandRunner:
# ---- 买卖原子操作 ----
def _sell(self, s, h, close_price, day, cash, trades, holdings) -> float:
def _sell(self, s, h, close_price, day, cash, trades, holdings, reason=None) -> float:
proceeds = h.qty * close_price * (1 - self.costs.slippage_rate)
commission = max(proceeds * self.costs.commission_rate, self.costs.min_commission)
fee = commission + proceeds * self.costs.stamp_tax_rate
cash += proceeds - fee
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=close_price)
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=close_price,
reason=reason)
)
trades.append(
Trade(
entry_date=h.entry_date, exit_date=day, symbol=s,
entry_price=h.entry_price, exit_price=close_price,
return_pct=(close_price / h.entry_price - 1.0) * 100,
# 买卖理由跟着成交走:成交明细里「为什么买、为什么卖」都齐
entry_reason=h.entry_reason,
exit_reason=reason,
)
)
holdings.pop(s, None)
return cash
def _buy(self, s, budget, d, close_price, day, holdings, notional) -> tuple[bool, float]:
def _buy(self, s, budget, d, close_price, day, holdings, notional, reason=None) -> tuple[bool, float]:
price_in = close_price * (1 + self.costs.slippage_rate)
commission = max(budget * self.costs.commission_rate, self.costs.min_commission)
invest = budget - commission
@@ -394,10 +535,13 @@ class HoldingBandRunner:
pv = prev.get(s) if prev is not None else float("nan")
if _nan(pv) or pv <= 0:
self._no_prev_close.add(s)
holdings[s] = _Holding(qty=qty, entry_date=day, entry_price=price_in, entry_ts=d)
holdings[s] = _Holding(
qty=qty, entry_date=day, entry_price=price_in, entry_ts=d, entry_reason=reason
)
notional.append(budget)
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=True, price=round(price_in, 4))
ActionRecord(date=day, symbol=s, signal="BUY", filled=True,
price=round(price_in, 4), reason=reason)
)
if s not in self._traded:
self._traded.add(s)
@@ -515,6 +659,7 @@ class HoldingBandRunner:
signal_history=self.signal_history,
fills=[a for a in self.signal_history if a.filled],
symbol_curves=curves,
factor_curves=build_factor_curves(self.factor_panels, self._weights_by_day),
turnover_pct=round(sum(notional) / max(init, 1) * 100, 2),
unimplemented=self._unimplemented(),
config_snapshot={}, # 由服务层填入 ComboRunSpec(含策略+成本快照)
@@ -567,10 +712,13 @@ def run_combo_backtest(
可为 None 表示该策略无额外过滤);由服务层用既有 selection 求值器装配。
返回结果的 config_snapshot 由调用方填入 ComboRunSpec(含策略+成本快照)以保证可复现。
"""
score, combined_elig = combine_strategy_scores(daily, strategies, eligibility_fns)
score, combined_elig, factor_panels = combine_strategy_scores(
daily, strategies, eligibility_fns
)
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
runner = HoldingBandRunner(
combo=combo, costs=costs, score=score, close=close, eligibility_fn=combined_elig,
factor_panels=factor_panels,
)
result = runner.run()
# 固化可复现规格(AGENT.md §21):组合参数 + 当时各策略定义 + 当时成本/复权
+16 -3
View File
@@ -57,11 +57,24 @@ def build_factor_panels(
daily: pd.DataFrame, factor_specs
) -> list[tuple[str, pd.DataFrame, float, str]]:
"""按 spec.factors 计算面板与权重(因子不存在即报错)。"""
panels: list[tuple[str, pd.DataFrame, float, str]] = []
return [
(defn.name, panel, weight, defn.direction)
for defn, panel, weight in build_factor_panels_full(daily, factor_specs)
]
def build_factor_panels_full(
daily: pd.DataFrame, factor_specs
) -> list[tuple[FactorDef, pd.DataFrame, float]]:
"""同 `build_factor_panels`,但把 `FactorDef` 一并带出来。
回测的「买卖理由」与「因子曲线」需要用到因子的显示名 / 方向 / 单位(`FactorDef`),
而只拿 name 就得回注册表再查一遍 —— 这里一次算完,避免同一次回测里重复计算面板。
"""
panels: list[tuple[FactorDef, pd.DataFrame, float]] = []
for fs in factor_specs:
defn: FactorDef
defn, panel = compute_factor(fs.name, daily)
panels.append((fs.name, panel, fs.weight, defn.direction))
panels.append((defn, panel, fs.weight))
return panels
+14 -4
View File
@@ -10,9 +10,10 @@ from typing import Protocol
import pandas as pd
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec
from app.quant.composite import build_factor_panels_full, composite_score
from app.quant.factors import FactorError, get_factor
from app.quant.local_engine import TopKBacktestRunner, run_spec_factor_test
from app.quant.selection import condition_needed_columns, score_panel_for_factors
from app.quant.selection import condition_needed_columns
# LocalEngine 路径恒需 close(TopK 收盘撮合 / 前瞻收益)
_CLOSE = {"close"}
@@ -73,7 +74,16 @@ class LocalEngine:
def run_backtest(
self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None
) -> BacktestResult:
# 评分面板与选股共用同一构建(v2 §25:回测与当前选股同引擎)
score = score_panel_for_factors(daily, spec.factors)
# 因子面板**只算一次**:复合分(选股)与原始值(买卖理由 / 因子曲线)同源。
# 若先 score_panel_for_factors 再单独算一遍原始面板,同一份行情会被算两遍,
# 且两次结果理论上可能分叉 —— 打分用的面板与理由里引用的面板必须是同一张。
# 复合分构建口径不变(与 selection.score_panel_for_factors 同为 z-score 加权和,
# v2 §25:回测与当前选股同引擎)。
panels = build_factor_panels_full(daily, spec.factors) # 未知因子在此抛 FactorError
score = composite_score([(d.name, p, w, d.direction) for d, p, w in panels])
# 同名因子只留一份(spec 已禁止重复因子名,这里再兜一层)
factor_panels = {d.name: (d, p) for d, p, _w in panels}
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
return TopKBacktestRunner(spec, score, close, eligibility_fn=eligibility_fn).run()
return TopKBacktestRunner(
spec, score, close, eligibility_fn=eligibility_fn, factor_panels=factor_panels
).run()
+16 -1
View File
@@ -117,6 +117,7 @@ class FactorDef:
param_specs: tuple[ParamSpec, ...] = () # 可编辑参数与约束(供目录/界面)
source: str = "builtin" # builtin(代码注册表)| custom(目录里创建的参数化实例)
label: str = "" # 中文显示名(含参数),如「动量(窗口 90,越高越好)」
unit: str | None = None # 因子值的量纲(% / 倍数 / 小数),供图表坐标轴与说明用
@property
def display(self) -> str:
@@ -141,6 +142,11 @@ class FactorTemplate:
requires: tuple[str, ...] = ("close",)
frequency: str = "daily"
direction_default: str = DIRECTION_HIGHER
# 因子值的量纲(由算法口径决定,不是可调参数):
# "%" = 数值本身就是百分数(股息率 5.2 读作 5.2%)
# "倍数" = 比值(量比 1.2 表示 1.2 倍)
# "小数" = 无单位比例,0.15 表示 15%(图上按小数显示,不做 ×100 换算)
unit: str | None = None
lookback_of: Callable[[Mapping[str, Any]], int] | None = None
check: Callable[[Mapping[str, Any]], str | None] | None = None # 跨参数约束
instances: tuple[tuple[str, Mapping[str, Any]], ...] = () # ((历史名, 参数), ...)
@@ -366,6 +372,7 @@ def build_factor_def(
param_specs=template.specs(),
source=source,
label=label_of(checked),
unit=template.unit,
)
@@ -496,6 +503,7 @@ register_template(
description="过去 {window} 个交易日收益率",
formula="close / close.shift({window}) - 1",
brief="动量:强者延续,适合趋势延续环境;窗口越短越敏感、越长越稳。",
unit="小数",
fn=lambda fields, params: _rolling_return(fields["close"], params[P_WINDOW]),
param_specs=(_window_spec(),),
lookback_of=lambda params: params[P_WINDOW],
@@ -514,6 +522,7 @@ register_template(
description="过去 {window} 个交易日收益率波动率",
formula="std(pct_change, {window})",
brief="低波动防御:近段波动小的股票抗跌,弱市/熊市阶段相对占优(方向越低越好)。",
unit="小数",
fn=lambda fields, params: _rolling_vol(fields["close"], params[P_WINDOW]),
param_specs=(_window_spec(),),
direction_default=DIRECTION_LOWER,
@@ -532,6 +541,7 @@ register_template(
description="收盘价相对 {window} 日最高价的接近程度",
formula="close / rolling_max(high, {window})",
brief="贴近 n 日高点(接近新高):趋势确认型强势股,常与动量互补;需配合市场热度判断。",
unit="倍数",
fn=lambda fields, params: fields["close"] / fields["high"].rolling(params[P_WINDOW]).max(),
param_specs=(_window_spec(),),
requires=("close", "high"),
@@ -559,6 +569,7 @@ register_template(
description="量比:{fast} 日均量 / {slow} 日均量",
formula="mean(volume, {fast}) / mean(volume, {slow})",
brief="量比放大提示资金关注(短线活跃型);高换手也伴随更高波动,注意与波动因子搭配。",
unit="倍数",
fn=lambda fields, params: (
fields["volume"].rolling(params[P_FAST]).mean()
/ fields["volume"].rolling(params[P_SLOW]).mean()
@@ -598,6 +609,7 @@ register_template(
description="{window} 日均线乖离率",
formula="(close - ma(close, {window})) / ma(close, {window})",
brief="均线乖离:上行趋势中正乖离偏强;乖离过大易回落,需警惕过热。",
unit="小数",
fn=lambda fields, params: (
(fields["close"] - fields["close"].rolling(params[P_WINDOW]).mean())
/ fields["close"].rolling(params[P_WINDOW]).mean()
@@ -615,6 +627,7 @@ register_template(
description="短期反转:过去 {window} 日收益率取负",
formula="-1 * (close / close.shift({window}) - 1)",
brief="短期反转:前期跌幅大的超跌反弹机会,适合震荡/修复行情。",
unit="小数",
fn=lambda fields, params: -1.0 * _rolling_return(fields["close"], params[P_WINDOW]),
param_specs=(_window_spec(),),
lookback_of=lambda params: params[P_WINDOW],
@@ -641,6 +654,7 @@ register_template(
"建议配合 dv_ratio 上限过滤与盈利质量条件使用。"
),
fn=lambda fields, params: fields["dv_ratio"],
unit="%",
requires=("dv_ratio",),
lookback_of=lambda params: 0, # 时点截面值,无滚动窗口
instances=(("dividend_yield", {P_DIRECTION: DIRECTION_HIGHER}),),
@@ -654,9 +668,10 @@ register_template(
description="股息率 TTM(近 12 个月滚动现金分红 / 总市值 × 100,%)",
formula="dv_ttm(Tushare daily_basic,逐日时点值)",
brief="同股息率,但口径为 TTM;与 dv_ratio 多数日期取值一致,可作交叉验证。",
unit="%",
fn=lambda fields, params: fields["dv_ttm"],
requires=("dv_ttm",),
lookback_of=lambda params: 0,
instances=(("dividend_yield_ttm", {P_DIRECTION: DIRECTION_HIGHER}),),
)
)
)
+230 -24
View File
@@ -34,6 +34,7 @@ from app.domain.entities.research import (
ResearchSpec,
SymbolCurve,
Trade,
TradeReason,
YearlyReturn,
)
from app.quant.composite import ( # noqa: F401 —— re-export(模块化后旧引用仍可用)
@@ -42,11 +43,28 @@ from app.quant.composite import ( # noqa: F401 —— re-export(模块化后
cross_sectional_zscore,
)
from app.quant.evaluation import run_factor_test
from app.quant.factors import FactorDef
from app.quant.portfolio import (
allocate_with_max_position,
equal_weight_budget,
unimplemented_notes,
)
from app.quant.trade_reasons import (
BUY_SKIP_HALTED,
BUY_SKIP_LIMIT_UP,
BUY_SKIP_MIN_COMMISSION,
BUY_SKIP_NO_CASH,
SELL_DEFER_HALTED,
SELL_DEFER_LIMIT_DOWN,
SELL_DROP_TOPN,
SELL_REBALANCE_FULL,
build_factor_curves,
buy_filled,
buy_skipped,
factor_values,
sell_deferred,
sell_filled,
)
TRADING_DAYS = 252
@@ -209,6 +227,7 @@ class TopKBacktestRunner:
score: pd.DataFrame,
close: pd.DataFrame,
eligibility_fn=None,
factor_panels: dict[str, tuple[FactorDef, pd.DataFrame]] | None = None,
) -> None:
self.spec = spec
close = close.copy()
@@ -221,12 +240,24 @@ class TopKBacktestRunner:
# 条件过滤(可选):(as_of: date) -> set[symbol] | None
# 由 Service 注入(复用 selection.eligible_symbols),保证回测与选股同一套求值逻辑
self.eligibility_fn = eligibility_fn
# 策略因子的**原始**面板(由 LocalEngine 用 build_factor_panels_full 一次算完后注入):
# 买卖理由里的「各因子当时的值」与 factor_curves 都从这里取,
# 与复合分用的是同一份数据 —— 理由不会去重算一遍因子而得到另一个数
self.factor_panels = factor_panels or {}
# M9-2:调仓意图与信号/成交记录(v3 §20.3/§22.3)
self.selection_history: list[RankedPick] = []
self.signal_history: list[ActionRecord] = []
# 当前候选池(择股日刷新):current_ranked 为全市场可评分排序,current_pool = 前 n
self.current_ranked: list[str] = []
self.current_pool: list[str] = []
# 择股日的**完整排名**与合格集:卖出理由要能说出「第几名」,以及
# 「是排名掉出去、还是根本不在候选池(被股票池/条件过滤)」
self._ranked_by_day: dict[pd.Timestamp, pd.Series] = {}
self._elig_by_day: dict[pd.Timestamp, set[str] | None] = {}
# 每个交易日的持仓市值(因子曲线按此加权;空仓日空 dict → 不落点)
self._weights_by_day: dict[pd.Timestamp, dict[str, float]] = {}
# 交易日位置索引:持有交易日按「交易日」计(跨周末不会虚增天数)
self._tday_pos: dict[pd.Timestamp, int] = {}
# 本次回测期内被持有过的股票(用于个股收益曲线)
self.traded_symbols: list[str] = []
self._traded: set[str] = set()
@@ -265,6 +296,9 @@ class TopKBacktestRunner:
shares: dict[str, float] = {}
entry_date: dict[str, date] = {}
entry_price: dict[str, float] = {}
# 建仓理由存进持仓结构:持有期间没有别的机会带上它,卖出成交时原样写进
# Trade.entry_reason,成交明细里「为什么买、为什么卖」才都齐
entry_reason: dict[str, TradeReason] = {}
equity_rows: dict[pd.Timestamp, float] = {}
trades: list[Trade] = []
positions: list[Position] = []
@@ -273,6 +307,8 @@ class TopKBacktestRunner:
# 个股收益曲线:cum = 该股「持仓期间」的累计净值(1.0 = 未涨未跌)
cum: dict[str, float] = {}
curve_rows: dict[str, list[CurvePoint]] = {}
# 持有交易日按交易日序号相减(自然日会跨周末失真)
self._tday_pos = {ts: i for i, ts in enumerate(self.close.index)}
def _value(d: pd.Timestamp) -> float:
total = cash
@@ -296,15 +332,19 @@ class TopKBacktestRunner:
# 上一次调仓挂起的顺延单作废(只在两次调仓之间有效)
pending = []
cash = self._rebalance(
d, cash, shares, entry_date, entry_price, trades, positions, notional,
pending,
d, cash, shares, entry_date, entry_price, entry_reason, trades, positions,
notional, pending,
)
elif pending:
cash = self._fill_pending(d, cash, shares, entry_date, entry_price, pending, notional)
cash = self._fill_pending(
d, cash, shares, entry_date, entry_price, entry_reason, pending, notional
)
equity_rows[d] = _value(d)
# 3) 建仓当日补「基准点」:成交在当日收盘、收益自次日起计;该点使 BUY 标注
# 能精确落在曲线上,也让多段持仓的分段起点可见(见 _mark_curve_dates)
self._mark_curve_dates(d, shares, cum, curve_rows)
# 4) 记录当日持仓市值(因子曲线按此加权;空仓日记录空 dict → 曲线不落点)
self._weights_by_day[d] = self._holding_weights(d, shares)
equity = pd.Series(equity_rows).sort_index()
return self._to_result(equity, trades, positions, notional, cum, curve_rows)
@@ -319,38 +359,131 @@ class TopKBacktestRunner:
eligible = self.eligibility_fn(d.date())
if eligible is not None:
score_d = score_d[score_d.index.isin(eligible)]
ranked = score_d.sort_values(ascending=False).index.tolist()
ranked = score_d.sort_values(ascending=False)
# 完整排名留下来:卖出理由要说「第几名」;只有 TopN 说不出这个数
self._ranked_by_day[d] = ranked
self._elig_by_day[d] = set(eligible) if eligible is not None else None
order = ranked.index.tolist()
n = self.spec.selection.top_n
pool = ranked[:n]
pool = order[:n]
day = d.date()
for rank, sym in enumerate(pool, start=1):
self.selection_history.append(
RankedPick(date=day, symbol=sym, rank=rank, score=round(float(score_d[sym]), 6))
)
return ranked, pool
return order, pool
# ---- 买卖理由的上下文(与组合引擎同口径) ----
def _rank_of(self, d: pd.Timestamp, symbol: str) -> dict:
"""该股在 `d` 日的排名上下文:rank / total / score / in_pool。
三者必须分开:**在池但排名靠后**、**已被股票池/条件过滤**(如转为 ST)、
**当日没有分数**(非择股日 / 数据缺失)—— 都写成「跌出 TopN」会掩盖真相。
非择股日没有当日排名,返回 None 而不是拿上一次择股的名次冒充。
"""
out: dict = {"rank": None, "total": None, "score": None, "in_pool": None}
ranked = self._ranked_by_day.get(d)
if ranked is None:
return out # 非择股日(如顺延成交发生在两次调仓之间):没有当日排名
elig = self._elig_by_day.get(d)
out["total"] = int(len(ranked))
out["in_pool"] = True if elig is None else (symbol in elig)
if symbol in ranked.index:
loc = ranked.index.get_loc(symbol)
if isinstance(loc, int):
out["rank"] = loc + 1
out["score"] = round(float(ranked.loc[symbol]), 6)
return out
def _reason_ctx(self, d: pd.Timestamp, symbol: str, *, top_n: int | None) -> dict:
"""理由构造器的公共参数:当日排名 + 各因子当时的**原始值**。
`factors` 取不到值就传 None(而不是空 dict):构造器据此不写这个字段,
空 dict 与「真的没有因子值」在 data 里应当可区分。
"""
ctx = self._rank_of(d, symbol)
return {
"rank": ctx["rank"],
"total": ctx["total"],
"top_n": top_n,
"score": ctx["score"],
"factors": factor_values(self.factor_panels, d, symbol) or None,
"not_in_pool": ctx["in_pool"] is False,
}
def _buy_skip_reason(
self, code: str, *, symbol: str, ctx: dict, close=None, prev_close=None, budget=None
) -> TradeReason:
"""买入未成交理由:只有涨停需要用「收盘 / 前收 vs 阈值」的真实比值解释。
文案与 data 一律由 trade_reasons 的构造器决定(两套引擎不许各写一份措辞)。
"""
if code == BUY_SKIP_LIMIT_UP:
return buy_skipped(
code, close=float(close), prev_close=float(prev_close),
limit_ratio=_limit_up_ratio(symbol), **ctx,
)
if code == BUY_SKIP_NO_CASH:
return buy_skipped(code, budget=budget, **ctx)
if code == BUY_SKIP_MIN_COMMISSION:
return buy_skipped(
code, budget=budget, min_commission=self.costs.min_commission, **ctx
)
return buy_skipped(code, **ctx)
def _holding_weights(self, d: pd.Timestamp, shares) -> dict[str, float]:
"""当日持仓市值(因子曲线加权用)。取不到价的持仓不参与,空仓日返回空 dict。"""
out: dict[str, float] = {}
for s, qty in shares.items():
if qty <= 0 or s not in self.close.columns:
continue
px = self.close.at[d, s] if d in self.close.index else None
if _nan(px) or px <= 0:
continue
out[s] = float(qty) * float(px)
return out
def _held_trading_days(self, entry_day: date, d: pd.Timestamp) -> int:
"""从入场到当前经过的**交易日**数(不含入场当日)。"""
e = self._tday_pos.get(pd.Timestamp(entry_day))
c = self._tday_pos.get(d)
if e is None or c is None:
return 0
return max(0, c - e)
# ---- 调仓(t 收盘执行,自 t+1 生效) ----
def _rebalance(
self, d, cash, shares, entry_date, entry_price, trades, positions, notional, pending
self, d, cash, shares, entry_date, entry_price, entry_reason, trades, positions,
notional, pending,
):
close_d = self.close.loc[d]
prev_d = self.prev_close.loc[d]
day = d.date()
n = self.spec.selection.top_n # 候选池大小:理由里的 TopN 口径
# 1) 卖出:逐持仓记录 SELL 意图与实际成交(跌停/无价则保留并说明)
for s in [s for s in shares if shares[s] > 0]:
c, p = close_d[s], prev_d[s]
held = self._held_trading_days(entry_date[s], d)
ctx = self._reason_ctx(d, s, top_n=n)
if _nan(c):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="无行情(停牌),保留持仓")
reject_reason="无行情(停牌),保留持仓",
reason=sell_deferred(SELL_DEFER_HALTED, cause="halted",
hold_days=held, **ctx))
)
continue # 停牌无价:保留
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False,
reject_reason="跌停无法卖出,保留到下一调仓")
reject_reason="跌停无法卖出,保留到下一调仓",
reason=sell_deferred(
SELL_DEFER_LIMIT_DOWN, cause="limit_down", hold_days=held,
close=float(c), prev_close=float(p),
limit_ratio=1.0 - (_limit_up_ratio(s) - 1.0), **ctx))
)
continue # 跌停无法卖出:保留到下一调仓
qty = shares[s]
@@ -358,8 +491,23 @@ class TopKBacktestRunner:
commission = max(proceeds * self.costs.commission_rate, self.costs.min_commission)
fee = commission + proceeds * self.costs.stamp_tax_rate
cash += proceeds - fee
# 本引擎的调仓是「全部卖出 → 按目标等权重新买入」(见 unimplemented):
# 若该股**当时仍排在 TopN 内**,卖它不是因为掉出榜单,而是策略本身的换仓方式,
# 用 SELL_REBALANCE_FULL 如实说明;只有确实不在池 / 名次掉出 / 当日无分数
# 才归 SELL_DROP_TOPN。数字照旧取当日真实值,code 只是把事实说准。
in_topn = (
ctx["rank"] is not None and not ctx["not_in_pool"] and ctx["rank"] <= n
)
sell_reason = sell_filled(
code=SELL_REBALANCE_FULL if in_topn else SELL_DROP_TOPN,
rank=ctx["rank"], total=ctx["total"], top_n=n,
score=ctx["score"], factors=ctx["factors"], hold_days=held, price=float(c),
return_pct=(float(c) / entry_price[s] - 1.0) * 100,
not_in_pool=ctx["not_in_pool"],
)
self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=float(c))
ActionRecord(date=day, symbol=s, signal="SELL", filled=True, price=float(c),
reason=sell_reason)
)
trades.append(
Trade(
@@ -369,11 +517,15 @@ class TopKBacktestRunner:
entry_price=entry_price[s],
exit_price=float(c),
return_pct=(float(c) / entry_price[s] - 1.0) * 100,
# 买卖理由跟着成交走:明细里「为什么买、为什么卖」两端齐全
entry_reason=entry_reason.get(s),
exit_reason=sell_reason,
)
)
shares[s] = 0.0
entry_date.pop(s, None)
entry_price.pop(s, None)
entry_reason.pop(s, None)
# 2) 买入意图:候选池(= selection_history 记录的那批)
picks = list(self.current_pool)
@@ -393,18 +545,23 @@ class TopKBacktestRunner:
)
)
def _buyable(sym) -> tuple[bool, str | None]:
def _buyable(sym) -> tuple[bool, str | None, str | None]:
"""(可否买入, 拒绝文案, 未成交原因 code)。
文案保持原样(既有结果里的 reject_reason 不许变),额外把原因 code 带出来,
让 ActionRecord.reason 用**词表里的 code** 表达同一件事,而不是去解析文案。
"""
c, p = close_d[sym], prev_d[sym]
if _nan(c):
return False, "无行情(停牌),无法买入"
return False, "无行情(停牌),无法买入", BUY_SKIP_HALTED
if _nan(p) or p <= 0:
# 无有效前收(数据窗口起点 / 长期停牌后复牌):无法判定涨停 → 按可买处理。
# 这里不计数:_buyable 是纯探测函数(替补扫描会重复调用同一标的),
# 计数放在真实成交路径 `_execute_buy`,避免把探测次数报成买入次数。
return True, None
return True, None, None
if c / p >= _limit_up_ratio(sym):
return False, "涨停,无法追买"
return True, None
return False, "涨停,无法追买", BUY_SKIP_LIMIT_UP
return True, None, None
# 目标名单:默认 = 池内前 x;allow_substitute=True 时从全市场排序继续往下找
targets: list[str] = []
@@ -412,7 +569,7 @@ class TopKBacktestRunner:
for sym in self.current_ranked:
if len(targets) >= self.spec.selection.x:
break
ok, _ = _buyable(sym)
ok, _, _code = _buyable(sym)
if ok:
targets.append(sym)
else:
@@ -437,17 +594,24 @@ class TopKBacktestRunner:
for s in targets:
budget = spends[s]
ctx = self._reason_ctx(d, s, top_n=n)
if budget <= 1e-9:
# 分配额过小(可用现金≈0 或上限约束):不成交且无额度可顺延,如实留痕
self.signal_history.append(
ActionRecord(
date=day, symbol=s, signal="BUY", filled=False,
reject_reason="分配额不足(可用现金≈0),未成交",
reason=self._buy_skip_reason(
BUY_SKIP_NO_CASH, symbol=s, ctx=ctx, budget=budget
),
)
)
continue
ok, reason = _buyable(s)
ok, reason, code = _buyable(s)
if not ok:
skip_reason = self._buy_skip_reason(
code, symbol=s, ctx=ctx, close=close_d[s], prev_close=prev_d[s]
)
if sel.defer_buy:
# 顺延:挂单到之后首个可成交交易日(本次不成交,资金留现金)
pending_specs.append((s, reason))
@@ -455,6 +619,7 @@ class TopKBacktestRunner:
ActionRecord(
date=day, symbol=s, signal="BUY", filled=False,
reject_reason=f"{reason},顺延到之后首个可成交日买入",
reason=skip_reason,
)
)
else:
@@ -462,16 +627,26 @@ class TopKBacktestRunner:
ActionRecord(
date=day, symbol=s, signal="BUY", filled=False,
reject_reason=reason or "不可买入",
reason=skip_reason,
)
)
continue
buy_reason = buy_filled(
rank=ctx["rank"], total=ctx["total"], top_n=n, score=ctx["score"],
factors=ctx["factors"], price=float(close_d[s]) * (1 + self.costs.slippage_rate),
budget=budget,
)
if not self._execute_buy(
s, budget, d, close_d[s], shares, entry_date, entry_price, notional
s, budget, d, close_d[s], shares, entry_date, entry_price, entry_reason,
notional, reason=buy_reason,
):
self.signal_history.append(
ActionRecord(
date=day, symbol=s, signal="BUY", filled=False,
reject_reason="预算不足以覆盖最低佣金,未成交",
reason=self._buy_skip_reason(
BUY_SKIP_MIN_COMMISSION, symbol=s, ctx=ctx, budget=budget
),
)
)
continue
@@ -484,10 +659,17 @@ class TopKBacktestRunner:
for sym in picks:
if sym in set(targets):
continue
_ok, reason = _buyable(sym)
_ok, reason, code = _buyable(sym)
if code is None:
code = BUY_SKIP_NO_CASH # 可买却未入选目标:资金分配已给别人
self.signal_history.append(
ActionRecord(date=day, symbol=sym, signal="BUY", filled=False,
reject_reason=reason or "资金不足(未成交)")
reject_reason=reason or "资金不足(未成交)",
reason=self._buy_skip_reason(
code, symbol=sym, ctx=self._reason_ctx(d, sym, top_n=n),
close=close_d.get(sym), prev_close=prev_d.get(sym),
budget=cash,
))
)
# 3) 记录调仓后仓位
@@ -510,7 +692,8 @@ class TopKBacktestRunner:
return cash
def _execute_buy(
self, s, budget, d, close_value, shares, entry_date, entry_price, notional
self, s, budget, d, close_value, shares, entry_date, entry_price, entry_reason,
notional, reason=None,
) -> bool:
"""按收盘价 + 滑点买入;佣金(含最低佣金)从投入资金中扣除。
@@ -527,13 +710,15 @@ class TopKBacktestRunner:
shares[s] = shares.get(s, 0.0) + invest / price_in
entry_date[s] = d.date()
entry_price[s] = price_in
# 建仓理由存进持仓结构:等真正卖出时写进 Trade.entry_reason(中间不会丢)
entry_reason[s] = reason
prev = self.prev_close.at[d, s] if d in self.prev_close.index else float("nan")
if _nan(prev) or prev <= 0:
self._no_prev_close_symbols.add(s) # 无前收→涨停不可判定,如实记入标注
notional.append(budget)
self.signal_history.append(
ActionRecord(date=d.date(), symbol=s, signal="BUY", filled=True,
price=round(price_in, 4))
price=round(price_in, 4), reason=reason)
)
if s not in self._traded:
self._traded.add(s)
@@ -542,7 +727,9 @@ class TopKBacktestRunner:
# ---- 顺延买入(defer_buy):之后逐日重试 ----
def _fill_pending(self, d, cash, shares, entry_date, entry_price, pending, notional):
def _fill_pending(
self, d, cash, shares, entry_date, entry_price, entry_reason, pending, notional
):
close_d = self.close.loc[d]
prev_d = self.prev_close.loc[d]
remaining: list[PendingBuy] = []
@@ -560,8 +747,18 @@ class TopKBacktestRunner:
if budget <= 1e-9:
remaining.append(order) # 无可用现金(理论上不会发生)
continue
# 顺延成交发生在两次调仓之间的普通交易日,**当日没有择股排名**:
# rank/total/score 一律为 None(不拿上次择股的名次冒充当日名次);
# 因子原始值与成交价/预算取成交当日的真实值。挂单当日「为什么被选中」
# 已记在那条 filled=False 的 BUY 信号上(reason=buy_skipped(...))。
fill_reason = buy_filled(
rank=None, total=None, top_n=None, score=None,
factors=factor_values(self.factor_panels, d, order.symbol) or None,
price=float(c) * (1 + self.costs.slippage_rate), budget=budget, deferred=True,
)
if not self._execute_buy(
order.symbol, budget, d, c, shares, entry_date, entry_price, notional
order.symbol, budget, d, c, shares, entry_date, entry_price, entry_reason,
notional, reason=fill_reason,
):
remaining.append(order) # 预算不足:保留挂单(下日现金可能已变化)
continue
@@ -686,6 +883,8 @@ class TopKBacktestRunner:
signal_history=self.signal_history,
fills=[a for a in self.signal_history if a.filled],
symbol_curves=curves,
# 因子曲线 = 当日持仓按市值加权的因子**原始值**(空仓日不落点,见 trade_reasons)
factor_curves=build_factor_curves(self.factor_panels, self._weights_by_day),
turnover_pct=round(sum(notional) / max(init, 1) * 100, 2),
unimplemented=self._unimplemented(curve_note),
config_snapshot=self.spec.model_dump(mode="json"),
@@ -728,6 +927,13 @@ class TopKBacktestRunner:
def _unimplemented(self, curve_note: str | None = None) -> list[str]:
notes = list(_DEFAULT_UNIMPLEMENTED) + unimplemented_notes(self.spec.portfolio)
# 如实说明买卖理由的覆盖边界:每个买卖点(含涨停/停牌/现金不足等未成交)都有理由,
# 但「排名在 TopN 之外、策略本来就无意买入」的候选不算买卖点(看选股明细即可)。
notes.append(
"买卖说明覆盖 signal_history 里的每个买卖点(含涨停/停牌/现金不足等未成交情形);"
"「当日排名在 TopN 之外、策略本来就无意买入」的候选不计为买卖点,"
"要看完整候选与名次请查选股明细(selection_history)"
)
if self._no_prev_close_symbols:
notes.append(
f"有 {len(self._no_prev_close_symbols)} 只标的成交时缺少上一有效收盘价,"
+8 -4
View File
@@ -21,10 +21,10 @@ from pathlib import Path
import pandas as pd
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec
from app.quant.composite import build_factor_panels_full
from app.quant.engine import QuantEngine
from app.quant.local_engine import (
TopKBacktestRunner,
build_factor_panels,
composite_score,
run_spec_factor_test,
)
@@ -69,8 +69,10 @@ class QlibEngine(QuantEngine):
`eligibility_fn`(选股条件/时点 ST 过滤)必须透传,否则条件与
`exclude_st` 在 Qlib 引擎下会被**静默忽略**(AGENT.md §24 禁止假装支持)。
"""
panels = build_factor_panels(daily, spec.factors)
score = composite_score(panels)
# 与 LocalEngine 同口径:复合分与买卖理由/因子曲线用**同一张**原始因子面板
full = build_factor_panels_full(daily, spec.factors)
score = composite_score([(d.name, p, w, d.direction) for d, p, w in full])
factor_panels = {d.name: (d, p) for d, p, _w in full}
self.qlib_dir.mkdir(parents=True, exist_ok=True)
uri = build_qlib_dataset(daily, self.qlib_dir)
@@ -85,7 +87,9 @@ class QlibEngine(QuantEngine):
)
close = close.sort_index()
result = TopKBacktestRunner(spec, score, close, eligibility_fn=eligibility_fn).run()
result = TopKBacktestRunner(
spec, score, close, eligibility_fn=eligibility_fn, factor_panels=factor_panels
).run()
result.config_snapshot = spec.model_dump(mode="json")
note = _ENGINE_NOTE
result.unimplemented = [note, *result.unimplemented]
+422
View File
@@ -0,0 +1,422 @@
"""买卖理由(数据化)与因子曲线的共享构造器。
**为什么单独一个模块**:两套回测引擎(`combo_engine` 组合回测、`local_engine`
单策略回测)都要回答同一个问题 ——「这一买一卖,当时的数字是多少?」。如果各写一份,
措辞、口径、字段名迟早分叉,用户在两处看到的「理由」会互相矛盾。
因此这里只放两件事:
1. **封闭的原因词表 + 构造器**:每个 `code` 对应一类可判定的原因,`text` 里带关键数字,
`data` 里放当时的原始数值(排名 / 候选数 / 综合分 / 因子原始值 / 持有交易日 / 预算 …)。
引擎只允许用词表里的 code(`REASON_CODES`),避免出现「文案随手写」的漂移。
2. **因子曲线的口径实现**:持仓股票的**权重加权平均原始值**,空仓日不落点、
不插值、不按方向取负(低为好的因子也原样画,方向由 `FactorCurve.direction` 说明)。
数值一律「引擎当时算出来的」:前端只展示,不推算 —— 界面上不该出现看起来像真的数字。
"""
from __future__ import annotations
import math
from collections.abc import Mapping
import pandas as pd
from app.domain.entities.research import CurvePoint, FactorCurve, TradeReason
from app.quant.factors import FactorDef
# ---------- 封闭原因词表 ----------
# 买入(成交)
BUY_ENTER = "buy_enter_topn"
BUY_DEFER_FILLED = "buy_defer_filled"
# 买入(未成交 / 未执行)
BUY_SKIP_LIMIT_UP = "buy_skip_limit_up"
BUY_SKIP_HALTED = "buy_skip_halted"
BUY_SKIP_NO_CASH = "buy_skip_no_cash"
BUY_SKIP_MIN_COMMISSION = "buy_skip_min_commission"
# 卖出(成交)
SELL_DROP_TOPN = "sell_drop_topn"
SELL_FORCE_TMAX = "sell_force_tmax"
# 卖出(成交):策略在调仓日**全量换仓**(先清仓再建仓),该股当时仍在 TopN 内。
# 为什么单列一个 code:单策略回测(TopK runner)的调仓语义就是「全清再买」,
# 被卖出的股票很可能仍然排在前列 —— 这时说「跌出 TopN」与 data 里的 rank=1 自相矛盾,
# 等于给用户一个假的解释。分开写才是如实描述。
SELL_REBALANCE_FULL = "sell_rebalance_full"
# 卖出(顺延 / 未成交)
SELL_DEFER_TMIN = "sell_defer_tmin"
SELL_DEFER_HALTED = "sell_defer_halted"
SELL_DEFER_LIMIT_DOWN = "sell_defer_limit_down"
REASON_CODES = frozenset(
{
BUY_ENTER,
BUY_DEFER_FILLED,
BUY_SKIP_LIMIT_UP,
BUY_SKIP_HALTED,
BUY_SKIP_NO_CASH,
BUY_SKIP_MIN_COMMISSION,
SELL_DROP_TOPN,
SELL_FORCE_TMAX,
SELL_REBALANCE_FULL,
SELL_DEFER_TMIN,
SELL_DEFER_HALTED,
SELL_DEFER_LIMIT_DOWN,
}
)
# 原因分类的中文短标签(前端筛选 / 表格上色用;改文案只改这里)
REASON_LABELS: dict[str, str] = {
BUY_ENTER: "按名次建仓",
BUY_DEFER_FILLED: "顺延后成交",
BUY_SKIP_LIMIT_UP: "涨停未买",
BUY_SKIP_HALTED: "停牌未买",
BUY_SKIP_NO_CASH: "现金不足",
BUY_SKIP_MIN_COMMISSION: "不足最低佣金",
SELL_DROP_TOPN: "跌出 TopN",
SELL_FORCE_TMAX: "持有超 Tmax",
SELL_REBALANCE_FULL: "调仓换仓卖出",
SELL_DEFER_TMIN: "Tmin 保护暂留",
SELL_DEFER_HALTED: "停牌未卖",
SELL_DEFER_LIMIT_DOWN: "跌停未卖",
}
def _num(v, digits: int = 6):
"""把 numpy/pandas 数值安全地压成原生 float(NaN/inf 一律不带进理由里)。"""
if v is None:
return None
try:
f = float(v)
except (TypeError, ValueError):
return None
if math.isnan(f) or math.isinf(f):
return None
return round(f, digits)
def factor_values(
factor_panels: Mapping[str, tuple[FactorDef, pd.DataFrame]],
day,
symbol: str,
) -> dict[str, float]:
"""该个股在 `day` 的各因子**原始值**(缺失因子不写进 data,不用 0 冒充)。
用交易日精确匹配:调仓日的打分与理由是同一份面板,所以这里取不到值就意味着
「该股当日无该因子值」,如实缺失比填 0 更可信。
"""
out: dict[str, float] = {}
for name, (_defn, panel) in factor_panels.items():
if day not in panel.index or symbol not in panel.columns:
continue
v = _num(panel.at[day, symbol])
if v is not None:
out[name] = v
return out
def _rank_data(
*,
rank: int | None,
total: int | None,
top_n: int | None,
score: float | None,
factors: dict[str, float] | None,
) -> dict:
data: dict = {}
if rank is not None:
data["rank"] = int(rank)
if total is not None:
data["total"] = int(total)
if top_n is not None:
data["top_n"] = int(top_n)
if score is not None:
data["score"] = _num(score)
if factors:
data["factors"] = factors
return data
def _rank_text(rank: int | None, total: int | None, top_n: int | None, score: float | None) -> str:
if rank is None:
return "调仓日综合分未给出名次"
parts = [f"综合分第 {rank}"]
if total:
parts.append(f"/{total}")
parts.append(" 名")
if top_n is not None:
parts.append(f"(TopN={top_n})")
if score is not None:
parts.append(f",综合分 {score:.4f}")
return "".join(parts)
def buy_filled(
*,
rank: int | None,
total: int | None,
top_n: int | None,
score: float | None,
factors: dict[str, float] | None,
price: float | None,
budget: float | None = None,
deferred: bool = False,
) -> TradeReason:
"""买入成交的理由:名次 + 综合分 + 各因子当时的原始值 + 成交价。"""
head = "顺延买入成交" if deferred else "调仓日选中并建仓"
text = f"{head}:{_rank_text(rank, total, top_n, score)}"
if price is not None:
text += f";成交价 {price:.2f} 元"
data = _rank_data(rank=rank, total=total, top_n=top_n, score=score, factors=factors)
if price is not None:
data["price"] = _num(price, 4)
if budget is not None:
data["budget"] = _num(budget, 2)
return TradeReason(code=BUY_DEFER_FILLED if deferred else BUY_ENTER, text=text, data=data)
def buy_skipped(
code: str,
*,
rank: int | None = None,
total: int | None = None,
top_n: int | None = None,
score: float | None = None,
factors: dict[str, float] | None = None,
close: float | None = None,
prev_close: float | None = None,
limit_ratio: float | None = None,
budget: float | None = None,
min_commission: float | None = None,
not_in_pool: bool = False,
) -> TradeReason:
"""买入未成交 / 未执行的理由(涨停、停牌、现金不足、佣金门槛)。"""
if code not in REASON_CODES:
raise ValueError(f"未知的买入未成交原因:{code}")
data = _rank_data(rank=rank, total=total, top_n=top_n, score=score, factors=factors)
base = _rank_text(rank, total, top_n, score)
if code == BUY_SKIP_LIMIT_UP:
ratio = (
_num(close / prev_close, 4)
if close is not None and prev_close not in (None, 0)
else None
)
text = f"{base},但当日涨停"
if ratio is not None:
text += f"(收盘 {close:.2f} / 前收 {prev_close:.2f} = {ratio:.3f}"
text += f" ≥ 涨停阈值 {limit_ratio:.3f})" if limit_ratio else ")"
text += ",无法追买"
if ratio is not None:
data["close_prev_ratio"] = ratio
if limit_ratio is not None:
data["limit_ratio"] = _num(limit_ratio, 4)
elif code == BUY_SKIP_HALTED:
text = f"{base},但当日无行情(停牌),无法买入"
elif code == BUY_SKIP_NO_CASH:
text = f"{base},但可用现金不足,未成交"
if budget is not None:
text += f"(可用预算 {budget:.2f} 元)"
elif code == BUY_SKIP_MIN_COMMISSION:
text = f"{base},但预算不足以覆盖最低佣金,未成交"
if budget is not None and min_commission is not None:
text += f"(预算 {budget:.2f} 元 < 最低佣金 {min_commission:.2f} 元)"
else: # pragma: no cover - 上面的分支已覆盖全部代码
text = base
if close is not None:
data["close"] = _num(close, 4)
if prev_close is not None:
data["prev_close"] = _num(prev_close, 4)
if budget is not None:
data["budget"] = _num(budget, 2)
if min_commission is not None:
data["min_commission"] = _num(min_commission, 2)
if not_in_pool:
data["in_pool"] = False
return TradeReason(code=code, text=text, data=data)
def sell_filled(
*,
code: str,
rank: int | None,
total: int | None,
top_n: int | None,
score: float | None,
factors: dict[str, float] | None,
hold_days: int,
tmin: int | None = None,
tmax: int | None = None,
price: float | None = None,
return_pct: float | None = None,
not_in_pool: bool = False,
) -> TradeReason:
"""卖出成交的理由(跌出 TopN / 持有超 Tmax / 全量换仓),带持有交易日与当时名次。
`not_in_pool=True` 表示该股已**不在候选池**(被股票池/条件过滤,如转为 ST),
与「在池内但排名掉出去」是两回事,文案与 data 都分开写。
`SELL_REBALANCE_FULL` 用于「策略每次调仓都先全清再建仓」的引擎:该股当时仍在前列,
卖它不是因为掉出 TopN,而是策略本身的调仓方式 —— 不能套用跌出 TopN 的说法。
"""
if code == SELL_FORCE_TMAX:
text = f"持有 {hold_days} 个交易日 > Tmax={tmax},强制了结(与排名无关)"
elif code == SELL_REBALANCE_FULL:
text = (
f"调仓日全量换仓:该策略每次调仓先清仓再按新名单建仓"
f"(该股当时仍在 TopN 内:{_rank_text(rank, total, top_n, score)});"
f"持有 {hold_days} 个交易日"
)
if tmin is not None:
text += f" ≥ Tmin={tmin}"
else:
code = SELL_DROP_TOPN
if not_in_pool:
text = f"调仓日已不在候选池(被股票池/条件过滤);持有 {hold_days} 个交易日"
else:
text = f"调仓日跌出 TopN:{_rank_text(rank, total, top_n, score)};持有 {hold_days} 个交易日"
if tmin is not None:
text += f" ≥ Tmin={tmin}"
data = _rank_data(rank=rank, total=total, top_n=top_n, score=score, factors=factors)
data["hold_days"] = int(hold_days)
if not_in_pool:
data["in_pool"] = False
if tmin is not None:
data["tmin"] = int(tmin)
if tmax is not None:
data["tmax"] = int(tmax)
if price is not None:
text += f";卖出价 {price:.2f} 元"
data["price"] = _num(price, 4)
if return_pct is not None:
data["return_pct"] = _num(return_pct, 4)
return TradeReason(code=code, text=text, data=data)
def sell_deferred(
code: str,
*,
cause: str,
rank: int | None = None,
total: int | None = None,
top_n: int | None = None,
score: float | None = None,
factors: dict[str, float] | None = None,
hold_days: int | None = None,
tmin: int | None = None,
tmax: int | None = None,
close: float | None = None,
prev_close: float | None = None,
limit_ratio: float | None = None,
not_in_pool: bool = False,
) -> TradeReason:
"""卖出未成交(顺延 / 暂留)的理由:Tmin 保护 / 停牌 / 跌停。
`tmax` 有值时说明是 Tmax 强制了结被卡住,文案据此区分 —— 两者后续行为不同
(Tmin 保护等到满 Tmin,Tmax 每天重试且不认排名)。
"""
if code not in REASON_CODES:
raise ValueError(f"未知的卖出顺延原因:{code}")
if tmax is not None:
head = f"持有 {hold_days} 个交易日超 Tmax={tmax},本应强制了结"
elif code == SELL_DEFER_TMIN:
why = (
"调仓日已不在候选池(被股票池/条件过滤)"
if not_in_pool
else f"掉出 TopN({_rank_text(rank, total, top_n, score)})"
)
head = f"{why}但仅持 {hold_days} 个交易日 < Tmin={tmin},按 Tmin 保护暂留"
else:
head = (
"调仓日已不在候选池(被股票池/条件过滤)"
if not_in_pool
else f"调仓日跌出 TopN({_rank_text(rank, total, top_n, score)})"
)
if cause == "tmin":
text = f"{head},暂留至满 Tmin"
elif cause == "halted":
text = f"{head},但当日无行情(停牌),顺延"
elif cause == "limit_down":
ratio = _num(close / prev_close, 4) if close is not None and prev_close not in (None, 0) else None
text = f"{head},但当日跌停"
if ratio is not None:
text += f"(收盘 {close:.2f} / 前收 {prev_close:.2f} = {ratio:.3f}"
text += f" ≤ 跌停阈值 {limit_ratio:.3f})" if limit_ratio else ")"
text += ",无法卖出,顺延"
if ratio is not None:
data_ratio = ratio
close_v, prev_v = _num(close, 4), _num(prev_close, 4)
else:
data_ratio, close_v, prev_v = None, None, None
else: # pragma: no cover - 调用方只传 halted / limit_down
text = f"{head},顺延"
data = _rank_data(rank=rank, total=total, top_n=top_n, score=score, factors=factors)
if not_in_pool:
data["in_pool"] = False
if hold_days is not None:
data["hold_days"] = int(hold_days)
if tmin is not None:
data["tmin"] = int(tmin)
if tmax is not None:
data["tmax"] = int(tmax)
if cause == "limit_down":
if data_ratio is not None:
data["close_prev_ratio"] = data_ratio
data["close"] = close_v
data["prev_close"] = prev_v
if limit_ratio is not None:
data["limit_ratio"] = _num(limit_ratio, 4)
if close is not None and "close" not in data and cause == "halted":
data["close"] = _num(close, 4)
return TradeReason(code=code, text=text, data=data)
# ---------- 因子曲线(持仓加权平均原始值) ----------
def weighted_average(values: Mapping[str, float], weights: Mapping[str, float]) -> float | None:
"""权重加权平均;没有任何有效样本时返回 None(调用方据此不落点)。"""
total_w = 0.0
acc = 0.0
for symbol, w in weights.items():
v = values.get(symbol)
if v is None or w <= 0:
continue
acc += float(v) * float(w)
total_w += float(w)
if total_w <= 0:
return None
return acc / total_w
def build_factor_curves(
factor_panels: Mapping[str, tuple[FactorDef, pd.DataFrame]],
weights_by_day: Mapping[object, dict[str, float]],
) -> list[FactorCurve]:
"""按「每日持仓权重」聚合出每个因子的曲线。
- `weights_by_day`:`{交易日: {symbol: 该股市值}}`,空仓日给空字典(不落点);
- 值 = 该日持仓上该因子的权重加权平均**原始值**(不做 z-score、不按方向取负);
- 曲线按因子键排序,保证同一份数据每次归档的顺序一致(便于 diff)。
"""
out: list[FactorCurve] = []
for name in sorted(factor_panels):
defn, panel = factor_panels[name]
points: list[CurvePoint] = []
for day, weights in weights_by_day.items():
if not weights or day not in panel.index:
continue
row = panel.loc[day]
values = {s: _num(row.get(s)) for s in weights}
avg = weighted_average(values, weights)
if avg is None:
continue
points.append(CurvePoint(date=day.date() if hasattr(day, "date") else day, value=round(avg, 6)))
out.append(
FactorCurve(
name=name,
label=defn.display,
direction=defn.direction,
unit=defn.unit,
points=points,
)
)
return out
+49
View File
@@ -6,9 +6,11 @@ import sys
from datetime import date, datetime
import pytest
from app.api.experiments import BULK_DELETE_MAX_IDS
from app.application.services import job_executor as je
from app.application.services.job_executor import execute_job
from app.domain.entities.research import (
ExperimentRecord,
JobRecord,
JobStatus,
ResearchSpec,
@@ -266,6 +268,53 @@ class TestJobsApi:
with TestClient(app) as client:
assert client.get("/api/jobs/JOB-NOPE").status_code == 404
def test_bulk_delete_reports_deleted_and_missing(self, seeded_api_db) -> None:
"""批量删除:成功 / 库里没有的 id **分开回报**,且真的从列表里消失。
为什么坚持分开回报:把「没找到」也算成功,界面上就会显示「已删除 4 个」
而实际只删了 2 个 —— 用户下次刷新发现还在,却查不出原因。
"""
spec_json = _spec().model_dump_json()
with sess_mod.SessionLocal() as session:
repo = SqlAlchemyExperimentRepository(session)
for eid in ("EXP-A", "EXP-B"):
repo.save(
ExperimentRecord(
id=eid,
kind="backtest",
spec_json=spec_json,
result_json="{}",
summary_text="自检用",
created_at=datetime(2026, 10, 1, 10, 0, 0),
)
)
session.commit()
with TestClient(app) as client:
# 重复 id 只算一次(EXP-A 出现两次)
resp = client.post(
"/api/experiments/bulk-delete",
json={"ids": ["EXP-A", "EXP-B", "EXP-A", "EXP-NOPE"]},
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] == ["EXP-A", "EXP-B"]
assert body["missing"] == ["EXP-NOPE"]
assert body["count"] == 2
left = {e["id"] for e in client.get("/api/experiments").json()}
assert not ({"EXP-A", "EXP-B"} & left)
def test_bulk_delete_validates_ids(self, seeded_api_db) -> None:
"""空列表 / 超上限 / 缺字段一律 422(不静默截断成前 N 个)。"""
with TestClient(app) as client:
assert client.post("/api/experiments/bulk-delete", json={"ids": []}).status_code == 422
assert client.post("/api/experiments/bulk-delete", json={}).status_code == 422
too_many = [f"EXP-{i}" for i in range(BULK_DELETE_MAX_IDS + 1)]
resp = client.post("/api/experiments/bulk-delete", json={"ids": too_many})
assert resp.status_code == 422
# 错误里必须写清上限,用户才知道要分几批
assert str(BULK_DELETE_MAX_IDS) in resp.text
def test_experiments_list_and_detail(self, seeded_api_db) -> None:
with TestClient(app) as client:
# 先跑一个 job 生成归档,再验证列表与详情
+503
View File
@@ -0,0 +1,503 @@
"""LocalEngine(单策略回测)买卖理由与因子曲线的常驻回归。
用户要求「回测结果里所有买卖点详细说明买卖理由,用数据说话」,因此这里验证的**不是文案
长什么样**,而是:理由里的每个数字都等于引擎当时算出来的值,能独立地对回算出来 ——
- 名次 / 候选数 / 综合分对得上复合分面板;`factors` 原始值对得上因子面板同一格;
- 涨跌停比值、预算、最低佣金、持有交易日对得上行情与配置;
- code 按**事实**选:仍在前列的清仓换仓(本引擎每次调仓先全清再建仓)用
`sell_rebalance_full`,只有确实不在池 / 名次掉出 / 当日无分数才用 `sell_drop_topn`;
- `Trade.entry_reason / exit_reason` 两端齐全,且与成交价一致(理由不是事后补的);
- `factor_curves` = 当日持仓**市值加权平均原始值**(用手算的加权值断言),空仓日不落点。
数据全部由本文件确定性合成(无外部依赖、无随机数),场景通过覆盖个别交易日的收盘价
(精确到「上一有效收盘 × 目标幅度」)来触发涨停 / 跌停 / 停牌。
"""
from __future__ import annotations
import math
from datetime import date
import pandas as pd
import pytest
from app.domain.entities.research import (
CostSpec,
FactorSpec,
ResearchSpec,
SelectionSpec,
UniverseSpec,
)
from app.quant.composite import build_factor_panels_full
from app.quant.engine import LocalEngine
from app.quant.selection import score_panel_for_factors
from app.quant.trade_reasons import (
BUY_DEFER_FILLED,
BUY_ENTER,
BUY_SKIP_HALTED,
BUY_SKIP_LIMIT_UP,
BUY_SKIP_MIN_COMMISSION,
BUY_SKIP_NO_CASH,
SELL_DEFER_HALTED,
SELL_DEFER_LIMIT_DOWN,
SELL_DROP_TOPN,
SELL_REBALANCE_FULL,
)
# 三只标的的确定性漂移:600000 最强、600002 最弱(动量排序稳定可预期)
_SYMS = (("600000.SH", 0.004), ("600001.SH", 0.0015), ("600002.SH", -0.002))
_START = date(2024, 3, 1)
_END = date(2024, 10, 31)
_APR_REBAL = date(2024, 4, 1) # 4 月调仓日
_MAY_REBAL = date(2024, 5, 1) # 5 月调仓日
_MOMENTUM = [FactorSpec(name="momentum_20")]
_VOLUME = [FactorSpec(name="volume_ratio_5_60")]
def _daily(*, overrides=None, nan_quotes=None, n=320, base=100.0) -> pd.DataFrame:
"""确定性合成日线长表:p[j] = p[j-1] * (1 + drift + 0.012·sin((j+i)·0.8))。
`overrides={(symbol, date): close}` 制造涨停/跌停,`nan_quotes` 制造停牌(无行情);
两者只改当日收盘(成交/撮合与因子都据此计算),保证场景可复现。
"""
dates = pd.bdate_range("2024-01-01", periods=n)
overrides = overrides or {}
nan_quotes = set(nan_quotes or ())
rows: list[dict] = []
for i, (sym, drift) in enumerate(_SYMS):
price = base
for j, d in enumerate(dates):
prev = price
price = price * (1 + drift + 0.012 * math.sin((j + i) * 0.8))
px = float(overrides.get((sym, d.date()), price))
if (sym, d.date()) in nan_quotes:
px = float("nan")
volume = float(1_000_000 + j * 1000 + i * 3000)
rows.append(
{
"symbol": sym,
"trade_date": d.date(),
"open": prev,
"high": float("nan") if math.isnan(px) else max(prev, px) * 1.008,
"low": float("nan") if math.isnan(px) else min(prev, px) * 0.992,
"close": px,
"volume": volume,
"amount": float("nan") if math.isnan(px) else px * volume,
}
)
return pd.DataFrame(rows)
def _spec(**over) -> ResearchSpec:
base = dict(
type="backtest",
universe=UniverseSpec(exclude_st=False, min_listing_days=0),
factors=list(_MOMENTUM),
selection=SelectionSpec(top_n=1),
rebalance="monthly",
period=(_START, _END),
costs=CostSpec(),
)
base.update(over)
return ResearchSpec(**base)
def _run(daily: pd.DataFrame, **spec_over):
return LocalEngine().run_backtest(daily, _spec(**spec_over))
def _by_code(result, code, *, signal=None, filled=None):
"""按 code 取记录(可再按 BUY/SELL 与是否成交过滤)。"""
return [
a
for a in result.signal_history
if a.reason is not None
and a.reason.code == code
and (signal is None or a.signal == signal)
and (filled is None or a.filled == filled)
]
def _panels(daily: pd.DataFrame, spec: ResearchSpec):
"""因子原始面板 {name: (defn, panel)}(与引擎注入理由的来源同一构建函数)。"""
return {d.name: (d, p) for d, p, _w in build_factor_panels_full(daily, spec.factors)}
def _close_panel(daily: pd.DataFrame) -> pd.DataFrame:
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
close.index = pd.to_datetime(close.index)
return close
def _close_before(close: pd.DataFrame, day: date, symbol: str) -> float:
"""`day` 之前最后一个有效收盘价(用来精确构造涨停/跌停的当日价)。"""
series = close[symbol].dropna()
return float(series[series.index < pd.Timestamp(day)].iloc[-1])
def _leader_at(daily: pd.DataFrame, factors, day: date) -> str:
"""该日复合分第一名(据此构造「涨停/停牌」的标的,避免写死代码)。"""
score = score_panel_for_factors(daily, factors)
return str(score.loc[pd.Timestamp(day)].dropna().idxmax())
def _held_on(daily: pd.DataFrame, day: date, **spec_over) -> set[str]:
"""基线回测在该日的持仓(据此决定把哪只标的的行情改成跌停/停牌)。"""
result = LocalEngine().run_backtest(daily, _spec(**spec_over))
return {p.symbol for p in result.positions if p.date == day}
# ---------- 1. 买入理由的数字来源 ----------
def test_buy_reason_numbers_come_from_engine():
"""买入理由的 rank/total/top_n/score 与 factors 原始值都能对回引擎面板。"""
daily = _daily()
spec = _spec()
result = LocalEngine().run_backtest(daily, spec)
buys = [a for a in result.signal_history if a.signal == "BUY" and a.filled]
assert buys, "主路径应有成交买入"
rec = buys[0]
reason = rec.reason
assert reason is not None and reason.code == BUY_ENTER
# 名次 / 候选数 / 综合分 == 复合分面板当日真实排序(独立算一遍)
score = score_panel_for_factors(daily, spec.factors)
d = pd.Timestamp(rec.date)
ranked = score.loc[d].dropna().sort_values(ascending=False)
assert reason.data["rank"] == ranked.index.get_loc(rec.symbol) + 1
assert reason.data["rank"] == 1
assert reason.data["total"] == len(ranked)
assert reason.data["top_n"] == spec.selection.top_n
assert reason.data["score"] == pytest.approx(round(float(ranked[rec.symbol]), 6))
# 因子原始值 == 因子面板同一格(不是重算、不是估算)
_defn, panel = _panels(daily, spec)["momentum_20"]
assert reason.data["factors"]["momentum_20"] == pytest.approx(
round(float(panel.at[d, rec.symbol]), 6)
)
assert f"第 {reason.data['rank']}" in reason.text and "成交价" in reason.text
# ---------- 2. 涨停未买 ----------
def test_buy_skip_limit_up_uses_real_ratio():
"""涨停未买:data 里的收盘/前收/比值/阈值全部来自当日行情与板块规则。"""
base_daily = _daily()
close0 = _close_panel(base_daily)
leader = _leader_at(base_daily, _MOMENTUM, _APR_REBAL)
prev = _close_before(close0, _APR_REBAL, leader)
daily = _daily(overrides={(leader, _APR_REBAL): prev * 1.12})
result = LocalEngine().run_backtest(daily, _spec())
skips = _by_code(result, BUY_SKIP_LIMIT_UP, signal="BUY", filled=False)
assert skips, "当日涨停应记录 buy_skip_limit_up"
rec = skips[0]
assert rec.symbol == leader
reason = rec.reason
close = _close_panel(daily)
d = pd.Timestamp(_APR_REBAL)
real_close = float(close.at[d, leader])
real_prev = float(close.ffill().shift(1).at[d, leader])
assert reason.data["close"] == pytest.approx(round(real_close, 4))
assert reason.data["prev_close"] == pytest.approx(round(real_prev, 4))
assert reason.data["close_prev_ratio"] == pytest.approx(round(real_close / real_prev, 4))
assert reason.data["close_prev_ratio"] == pytest.approx(1.12)
assert reason.data["limit_ratio"] == pytest.approx(round(1.0 + (1.099 - 1.0), 4))
assert reason.data["close_prev_ratio"] >= reason.data["limit_ratio"]
assert rec.reject_reason == "涨停,无法追买" # 既有文案未被理由改动
assert "涨停" in reason.text and "无法追买" in reason.text
# ---------- 3. 停牌未买 / 停牌未卖 ----------
def test_buy_skip_halted_keeps_rank_and_reject_text():
"""停牌未买:标的仍被选中(有真实名次),只是当日无行情无法成交。"""
base_daily = _daily()
# 用「只依赖 volume」的因子:close 缺失时该股仍能进候选池,才能走到执行层停牌分支
leader = _leader_at(base_daily, _VOLUME, _MAY_REBAL)
daily = _daily(nan_quotes={(leader, _MAY_REBAL)})
result = LocalEngine().run_backtest(daily, _spec(factors=list(_VOLUME)))
skips = _by_code(result, BUY_SKIP_HALTED, signal="BUY", filled=False)
assert skips, "停牌应记录 buy_skip_halted"
rec = skips[0]
assert rec.symbol == leader
assert rec.reject_reason == "无行情(停牌),无法买入" # 既有文案未变
assert rec.reason.data["rank"] == 1 # 停牌的是被选中的第一名,不是随便一只
assert "停牌" in rec.reason.text
def test_sell_defer_halted_keeps_position():
"""停牌未卖:顺延理由带真实持有交易日,且 reject_reason 保持原文案。"""
base_daily = _daily()
held = _held_on(base_daily, _MAY_REBAL)
assert held, "基线在 5 月调仓日应有持仓"
daily = _daily(nan_quotes={(s, _MAY_REBAL) for s in held})
result = _run(daily)
defers = _by_code(result, SELL_DEFER_HALTED, signal="SELL", filled=False)
assert defers, "持仓股无行情应记录 sell_defer_halted"
rec = defers[0]
assert rec.symbol in held
assert rec.reject_reason == "无行情(停牌),保留持仓"
assert rec.reason.data["hold_days"] > 0
# 当日没有该股的成交卖出(停牌只是顺延,仓位保留)
assert not [
a
for a in result.signal_history
if a.symbol == rec.symbol
and a.date == _MAY_REBAL
and a.signal == "SELL"
and a.filled
]
# ---------- 4. 跌停未卖 ----------
def test_sell_defer_limit_down_uses_real_ratio():
"""跌停未卖:data 里的收盘/前收/比值/阈值与行情一致(比值 ≤ 阈值)。"""
base_daily = _daily()
close0 = _close_panel(base_daily)
held = _held_on(base_daily, _APR_REBAL)
assert held
overrides = {
(s, _APR_REBAL): _close_before(close0, _APR_REBAL, s) * 0.90 for s in held
}
daily = _daily(overrides=overrides)
result = _run(daily)
defers = _by_code(result, SELL_DEFER_LIMIT_DOWN, signal="SELL", filled=False)
assert defers, "持仓股跌停应记录 sell_defer_limit_down"
rec = defers[0]
reason = rec.reason
close = _close_panel(daily)
d = pd.Timestamp(_APR_REBAL)
assert reason.data["close"] == pytest.approx(round(float(close.at[d, rec.symbol]), 4))
assert reason.data["prev_close"] == pytest.approx(
round(float(close.ffill().shift(1).at[d, rec.symbol]), 4)
)
assert reason.data["close_prev_ratio"] == pytest.approx(0.90)
assert reason.data["limit_ratio"] == pytest.approx(0.901)
assert reason.data["close_prev_ratio"] <= reason.data["limit_ratio"]
assert reason.data["hold_days"] > 0
assert rec.reject_reason == "跌停无法卖出,保留到下一调仓"
assert "跌停" in reason.text and "顺延" in reason.text
# ---------- 5. 现金不足 / 不足最低佣金 ----------
def test_buy_skip_no_cash_when_budget_exhausted():
"""现金分配耗尽后,池内第二只留痕「资金不足」,budget = 当时真实剩余现金。"""
daily = _daily()
result = _run(daily, selection=SelectionSpec(top_n=2, hold_top_x=1))
skips = _by_code(result, BUY_SKIP_NO_CASH, signal="BUY", filled=False)
assert skips
no_cash = [a for a in skips if a.reject_reason == "资金不足(未成交)"]
assert no_cash, "替补路径下池内被跳过的标的应给出 buy_skip_no_cash"
assert no_cash[0].reason.data["budget"] == pytest.approx(0.0) # 唯一目标吃光现金
assert "可用预算" in no_cash[0].reason.text
def test_buy_skip_min_commission_from_budget_and_config():
"""不足最低佣金:budget = 等权分配额、min_commission = 配置值,两者都来自引擎。"""
daily = _daily()
result = LocalEngine().run_backtest(
daily,
_spec(costs=CostSpec(min_commission=5.0), initial_capital=4.0),
)
skips = _by_code(result, BUY_SKIP_MIN_COMMISSION, signal="BUY", filled=False)
assert skips
reason = skips[0].reason
assert reason.data["budget"] == pytest.approx(4.0) # 4 元全给唯一目标
assert reason.data["min_commission"] == pytest.approx(5.0)
assert reason.data["budget"] < reason.data["min_commission"]
assert skips[0].reject_reason == "预算不足以覆盖最低佣金,未成交"
assert result.trades == [] # 该场景确实一笔未成
# ---------- 6. 顺延买入成交 ----------
def test_buy_defer_filled_after_limit_up():
"""顺延买入:挂单当日涨停未买,之后按真实成交日的价格/因子值成交(不编当日名次)。"""
base_daily = _daily()
close0 = _close_panel(base_daily)
leader = _leader_at(base_daily, _MOMENTUM, _APR_REBAL)
prev = _close_before(close0, _APR_REBAL, leader)
daily = _daily(overrides={(leader, _APR_REBAL): prev * 1.12})
spec = _spec(
selection=SelectionSpec(top_n=1, allow_substitute=False, defer_buy=True)
)
result = LocalEngine().run_backtest(daily, spec)
pending = _by_code(result, BUY_SKIP_LIMIT_UP, signal="BUY", filled=False)
filled = _by_code(result, BUY_DEFER_FILLED, signal="BUY", filled=True)
assert pending and filled, "顺延应有「挂单当日涨停」+「之后成交」两条记录"
assert pending[0].symbol == filled[0].symbol == leader
assert pending[0].date == _APR_REBAL
assert filled[0].date > _APR_REBAL # 只在之后的交易日补成交,不回溯
reason = filled[0].reason
# 成交日不是择股日 → 不拿旧名次冒充当日名次
assert "rank" not in reason.data and "score" not in reason.data
# 因子原始值 / 成交价 / 预算取成交当日的真实值
_defn, panel = _panels(daily, spec)["momentum_20"]
fd = pd.Timestamp(filled[0].date)
assert reason.data["factors"]["momentum_20"] == pytest.approx(
round(float(panel.at[fd, leader]), 6)
)
close = _close_panel(daily)
price_in = float(close.at[fd, leader]) * (1 + spec.costs.slippage_rate)
assert reason.data["price"] == pytest.approx(round(price_in, 4))
assert reason.data["budget"] > 0
# 成交明细里的建仓理由是「顺延成交」,不是笼统的按名次建仓
trade = next(
t for t in result.trades if t.symbol == leader and t.entry_date == filled[0].date
)
assert trade.entry_reason is not None and trade.entry_reason.code == BUY_DEFER_FILLED
# ---------- 7. 成交卖出:全量换仓 vs 跌出 TopN ----------
def test_sell_rebalance_full_when_still_in_topn():
"""仍排在 TopN 内却被清仓(本引擎「先全清再建仓」)→ sell_rebalance_full。
这是本次新增 code 的关键回归:老实现会把它说成「跌出 TopN」,与 data 里的
rank=1/top_n=1 自相矛盾 —— 用假解释掩盖真实原因。
"""
daily = _daily()
spec = _spec() # top_n=1:最强的 600000.SH 每月都排第一
result = LocalEngine().run_backtest(daily, spec)
sells = [a for a in result.signal_history if a.signal == "SELL" and a.filled]
assert sells, "调仓应产生成交卖出"
rec = sells[0]
reason = rec.reason
assert reason.code == SELL_REBALANCE_FULL
assert reason.data["rank"] == 1
assert reason.data["top_n"] == 1
assert reason.data["rank"] <= reason.data["top_n"] # 关键:当时仍在前列
assert reason.data["hold_days"] > 0
assert "全量换仓" in reason.text
# 该股当日确实有分(不是「当日无分数」才落到这个 code)
score = score_panel_for_factors(daily, spec.factors)
assert not math.isnan(float(score.at[pd.Timestamp(rec.date), rec.symbol]))
# 关键 data(跑 -s 时可读;失败时也在断言里可见)
print(
f"[sell_rebalance_full] code={reason.code} rank={reason.data['rank']} "
f"total={reason.data['total']} top_n={reason.data['top_n']} "
f"hold_days={reason.data['hold_days']}"
)
def test_sell_drop_topn_when_filtered_out_of_pool():
"""被股票池/条件过滤(已不在候选池)才归 sell_drop_topn,与换仓卖出分开。"""
daily = _daily()
spec = _spec()
result = LocalEngine().run_backtest(
daily,
spec,
eligibility_fn=lambda as_of: {"600001.SH"} if as_of >= _APR_REBAL else None,
)
drops = _by_code(result, SELL_DROP_TOPN, signal="SELL", filled=True)
assert drops, "持仓股被条件过滤后应卖出并归 sell_drop_topn"
reason = drops[0].reason
assert reason.data["in_pool"] is False
assert "已不在候选池" in reason.text
# 对照:换仓卖出的 code 不应出现在同一条记录上
assert reason.code != SELL_REBALANCE_FULL
def test_trade_carries_both_end_reasons():
"""成交明细两端齐全,且理由里的价格与 Trade 的成交价一致(理由跟着成交走)。"""
daily = _daily()
result = _run(daily)
assert result.trades
trade = result.trades[0]
assert trade.entry_reason is not None and trade.entry_reason.code == BUY_ENTER
assert trade.exit_reason is not None and trade.exit_reason.code == SELL_REBALANCE_FULL
assert trade.entry_reason.data["price"] == pytest.approx(round(trade.entry_price, 4))
assert trade.exit_reason.data["price"] == pytest.approx(round(trade.exit_price, 4))
# ---------- 8. 因子曲线:市值加权原始值 / 空仓日不落点 ----------
def test_factor_curves_are_market_value_weighted():
"""曲线值 == 当日持仓按市值加权平均的因子原始值(用反推股数的手算值断言)。"""
daily = _daily()
spec = _spec(selection=SelectionSpec(top_n=2)) # 两只持仓,权重会随行情漂移
result = LocalEngine().run_backtest(daily, spec)
_defn, panel = _panels(daily, spec)["momentum_20"]
curve = next(c for c in result.factor_curves if c.name == "momentum_20")
points = {p.date: p.value for p in curve.points}
assert points, "有持仓就应有因子曲线点"
# 从首个「两只持仓」的调仓日反推股数(引擎给的 weight × 当日权益 ÷ 当日收盘)
close = _close_panel(daily)
by_day: dict = {}
for pos in result.positions:
by_day.setdefault(pos.date, {})[pos.symbol] = pos.weight
d0 = next(d for d in sorted(by_day) if len(by_day[d]) == 2)
equity0 = next(q.value for q in result.equity_curve if q.date == d0)
qty = {
s: by_day[d0][s] * equity0 / float(close.at[pd.Timestamp(d0), s])
for s in by_day[d0]
}
# 取之后第 10 个交易日(仍在同一持仓期内):权重已随价格漂移,非等权
idx = list(close.index)
d1 = idx[idx.index(pd.Timestamp(d0)) + 10]
market_value = {s: qty[s] * float(close.at[d1, s]) for s in qty}
values = {s: float(panel.at[d1, s]) for s in market_value}
manual = sum(values[s] * market_value[s] for s in values) / sum(market_value.values())
equal = sum(values.values()) / len(values)
assert abs(market_value["600000.SH"] - market_value["600001.SH"]) > 1.0 # 确实漂移了
assert abs(equal - manual) > 1e-5 # 等权平均对不上 → 能区分「市值加权」
assert points[d1.date()] == pytest.approx(round(manual, 6), abs=1e-6)
# 曲线上是因子的**原始值**(未 z-score、未按方向取负):量级与动量本身一致
assert all(abs(v) < 5 for v in points.values())
def test_factor_curves_skip_days_without_holdings():
"""空仓日不落点(不插值、不用 0 填充):涨停买不进且不替补的整月没有曲线点。"""
base_daily = _daily()
close0 = _close_panel(base_daily)
leader = _leader_at(base_daily, _MOMENTUM, _APR_REBAL)
prev = _close_before(close0, _APR_REBAL, leader)
daily = _daily(overrides={(leader, _APR_REBAL): prev * 1.12})
spec = _spec(
selection=SelectionSpec(top_n=1, allow_substitute=False, defer_buy=False)
)
result = LocalEngine().run_backtest(daily, spec)
curve = next(c for c in result.factor_curves if c.name == "momentum_20")
point_dates = {p.date for p in curve.points}
april = {d.date() for d in pd.bdate_range("2024-04-01", "2024-04-30")}
assert not (point_dates & april), "4 月空仓(涨停未买且不替补),不应有任何曲线点"
assert date(2024, 3, 1) in point_dates # 3 月建仓后有持仓 → 有点
period_days = {d.date() for d in pd.bdate_range(_START, _END)}
assert len(point_dates) < len(period_days) # 有缺口 = 没按交易日补齐
def test_factor_curves_empty_when_no_fills():
"""一笔都没成交(预算不足最低佣金)→ 面板非空但曲线 0 个点,而不是一堆 0 值。"""
daily = _daily()
result = LocalEngine().run_backtest(
daily, _spec(costs=CostSpec(min_commission=5.0), initial_capital=4.0)
)
assert result.trades == []
assert result.factor_curves, "因子曲线按策略因子输出(即便没成交)"
assert all(c.points == [] for c in result.factor_curves)
+241
View File
@@ -0,0 +1,241 @@
"""买卖理由(数据化)与因子曲线的单测。
用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线」。
因此这里验证的是**数字真的来自引擎当时计算**,而不是后补的文案:
1. 买入理由带名次 / 候选数 / 综合分 / 每个因子当时的原始值;
2. 卖出理由区分「跌出 TopN(第几名)」「被股票池过滤」「持有超 Tmax」;
3. Tmin 保护、涨停未买等未成交点也有结构化理由;
4. `Trade.entry_reason / exit_reason` 跟着成交记录走;
5. `factor_curves` = 持仓权重加权平均的**原始值**,空仓日不落点。
"""
from __future__ import annotations
from datetime import date, timedelta
import pandas as pd
import pytest
from app.domain.entities.combo import BacktestCombo
from app.domain.entities.research import CostSpec
from app.quant.combo_engine import HoldingBandRunner
from app.quant.factors import get_factor
def _days(n: int = 12, start: date = date(2024, 1, 2)) -> list[pd.Timestamp]:
out: list[date] = []
d = start
while len(out) < n:
if d.weekday() < 5:
out.append(d)
d += timedelta(days=1)
return [pd.Timestamp(x) for x in out]
def _close(days, series: dict[str, list[float]]) -> pd.DataFrame:
return pd.DataFrame(series, index=pd.DatetimeIndex(days))
def _combo(**over) -> BacktestCombo:
base = dict(
name="理由单测",
strategy_ids=["S1"],
initial_capital=1_000_000.0,
hold_count=1,
hold_min_days=0,
hold_max_days=None,
rebalance_freq="daily",
period=(date(2024, 1, 2), date(2024, 1, 31)),
)
base.update(over)
return BacktestCombo(**base)
def _factor_panels(days, values: dict[str, float]):
"""用一个真实注册因子(momentum_20)承载合成面板:label/方向/单位来自注册表。"""
panel = pd.DataFrame(
{sym: [v] * len(days) for sym, v in values.items()}, index=pd.DatetimeIndex(days)
)
return {"momentum_20": (get_factor("momentum_20")[0], panel)}
def _run(
*,
score_rows: list[dict[str, float]],
close_series: dict[str, list[float]],
factor_values: dict[str, float] | None = None,
eligibility_fn=None,
**combo_over,
):
days = _days(len(score_rows))
score = pd.DataFrame(score_rows, index=pd.DatetimeIndex(days))
close = _close(days, close_series)
runner = HoldingBandRunner(
combo=_combo(**combo_over),
costs=CostSpec(),
score=score,
close=close,
eligibility_fn=eligibility_fn,
factor_panels=_factor_panels(days, factor_values or {"A": 0.1, "B": 0.2}),
)
return runner.run(), days
# ---------- 买入理由 ----------
def test_buy_reason_has_real_numbers():
"""买入成交的理由 = 名次 + 候选数 + 综合分 + 各因子当时的原始值(全部来自引擎)。"""
result, _ = _run(
score_rows=[{"A": 0.9, "B": 0.5}] * 6,
close_series={"A": [100.0] * 6, "B": [100.0] * 6},
factor_values={"A": 0.123, "B": 0.456},
)
buys = [a for a in result.signal_history if a.signal == "BUY" and a.filled]
assert len(buys) == 1
r = buys[0].reason
assert r is not None
assert r.code == "buy_enter_topn"
assert r.data["rank"] == 1
assert r.data["total"] == 2
assert r.data["top_n"] == 1
assert r.data["score"] == pytest.approx(0.9)
# 因子原始值(面板里 A=0.123)——区间内买入理由必须能对上这个数
assert r.data["factors"]["momentum_20"] == pytest.approx(0.123)
assert "第 1" in r.text and "0.9000" in r.text
def test_sell_reason_ranks_and_hold_days():
"""跌出 TopN 的卖出理由要说出「第几名掉出去」与持有交易日。"""
# 第 1 天 A 第一 → 买入 A;第 3 天起 B 第一 → 卖出 A(名次 2/2)
rows = [{"A": 0.9, "B": 0.5}, {"A": 0.9, "B": 0.5}, {"A": 0.1, "B": 0.9}] + [
{"A": 0.1, "B": 0.9}
] * 3
result, _ = _run(
score_rows=rows,
close_series={"A": [100.0] * 6, "B": [100.0] * 6},
hold_min_days=0,
)
sells = [a for a in result.signal_history if a.signal == "SELL" and a.filled]
assert len(sells) == 1
r = sells[0].reason
assert r is not None
assert r.code == "sell_drop_topn"
assert r.data["rank"] == 2
assert r.data["total"] == 2
assert r.data["hold_days"] == 2 # 第 1 天买、第 3 天卖 → 2 个交易日
assert "第 2/2" in r.text
# 成交明细里的买卖理由两端齐全
trade = result.trades[0]
assert trade.entry_reason is not None and trade.entry_reason.code == "buy_enter_topn"
assert trade.exit_reason is not None and trade.exit_reason.code == "sell_drop_topn"
def test_sell_reason_not_in_pool_is_distinct():
"""被股票池/条件过滤掉(不在候选池)≠ 排名掉出去:理由要分开写。"""
result, _ = _run(
score_rows=[{"A": 0.9, "B": 0.5}] * 4,
close_series={"A": [100.0] * 4, "B": [100.0] * 4},
# 第 4 天把 B 之外的 A 挡在候选池外(A 持有中,属于「已不在候选池」)
eligibility_fn=lambda as_of: {"B"} if as_of >= date(2024, 1, 5) else None,
)
sells = [a for a in result.signal_history if a.signal == "SELL" and a.filled]
assert sells, "A 不在候选池后应被卖出"
r = sells[0].reason
assert r is not None and r.code == "sell_drop_topn"
assert r.data["in_pool"] is False
assert "已不在候选池" in r.text
def test_sell_reason_tmax_force_exit():
"""Tmax 强制了结:理由说明「持有 N 天 > Tmax」,与排名无关。"""
result, _ = _run(
score_rows=[{"A": 0.9, "B": 0.5}] * 8,
close_series={"A": [100.0] * 8, "B": [100.0] * 8},
hold_max_days=3,
)
forced = [
a for a in result.signal_history if a.signal == "SELL" and a.filled
and a.reason is not None and a.reason.code == "sell_force_tmax"
]
assert forced, "超 Tmax 应有强制了结"
r = forced[0].reason
assert r is not None
assert r.data["hold_days"] > r.data["tmax"] == 3
assert "Tmax=3" in r.text
def test_tmin_protection_reason():
"""未满 Tmin 掉出 TopN:理由写明「仅持 N 天 < Tmin,暂留」。"""
rows = [{"A": 0.9, "B": 0.5}, {"A": 0.1, "B": 0.9}] + [{"A": 0.1, "B": 0.9}] * 4
result, _ = _run(
score_rows=rows,
close_series={"A": [100.0] * 6, "B": [100.0] * 6},
hold_min_days=3,
)
deferred = [
a for a in result.signal_history
if not a.filled and a.reason is not None and a.reason.code == "sell_defer_tmin"
]
assert deferred, "未满 Tmin 应记录暂留理由"
r = deferred[0].reason
assert r is not None
assert r.data["hold_days"] < r.data["tmin"] == 3
assert "Tmin=3" in r.text and "暂留" in r.text
def test_buy_blocked_by_limit_up_reason():
"""涨停无法追买:理由里带「收盘 / 前收 = 比值 ≥ 阈值」的真实数字。"""
# 第 3 天 A 相对前收涨 10% 以上(600xxx 主板阈值 1.099)→ 当日买不进
a = [100.0, 100.0, 111.0, 111.0]
result, _ = _run(
score_rows=[{"A": 0.9, "B": 0.5}] * 2 + [{"A": 0.9, "B": 0.5}] * 2,
close_series={"A": a, "B": [100.0] * 4},
# 前几天 A 不可选,逼到第 3 天涨停时才想买
eligibility_fn=lambda as_of: {"B"} if as_of < date(2024, 1, 4) else {"A", "B"},
)
blocked = [
a_ for a_ in result.signal_history
if a_.signal == "BUY" and not a_.filled and a_.reason is not None
and a_.reason.code == "buy_skip_limit_up"
]
assert blocked, "涨停日应记录未成交理由"
r = blocked[0].reason
assert r is not None
assert r.data["close_prev_ratio"] == pytest.approx(1.11, abs=1e-3)
assert r.data["limit_ratio"] == pytest.approx(1.099)
assert "涨停" in r.text
# ---------- 因子曲线 ----------
def test_factor_curve_is_holding_weighted_raw_value():
"""因子曲线 = 持仓权重加权平均的原始值(有 label/方向/单位),空仓日不落点。"""
rows = [{"A": 0.9, "B": 0.5}] * 6
result, days = _run(
score_rows=rows,
close_series={"A": [100.0] * 6, "B": [200.0] * 6},
factor_values={"A": 0.2, "B": 0.8},
)
assert len(result.factor_curves) == 1
fc = result.factor_curves[0]
assert fc.name == "momentum_20"
assert fc.direction == "higher_is_better"
assert fc.unit == "小数"
assert fc.label.startswith("动量")
# 只买 A(N=1),因子值恒为 A 的 0.2;第一天调仓在收盘后建仓 → 第一天也落点
assert all(p.value == pytest.approx(0.2) for p in fc.points)
assert len(fc.points) == len(days)
def test_factor_curve_skips_empty_holding_days():
"""空仓日不落点(不插值、不用 0 假填充),曲线点数少于交易日数。"""
# 只有第 3 天有股票可选:之前空仓,之后持仓
rows = [{"A": float("nan"), "B": float("nan")}] * 2 + [{"A": 0.9, "B": 0.5}] * 4
result, days = _run(
score_rows=rows,
close_series={"A": [100.0] * 6, "B": [100.0] * 6},
)
fc = result.factor_curves[0]
assert 0 < len(fc.points) < len(days)
+50 -4
View File
@@ -508,6 +508,40 @@ PYTHONPATH=. .venv/bin/python -m app.cli.restore_experiment_from_job \
**归档完整度会明示**:归档页顶部标注「归档完整 N/N」;若曲线因体积预算被裁剪,或该快照产生于
完整存档上线之前(个股曲线只有 60 只),页面直接显示「**归档不完整**」并给出差异与「以此参数重跑」入口。
> **买卖理由 + 因子曲线 + 曲线放大**(2026-10):回测结果不再只给「成交了哪些」,
> 而是回答「**为什么买 / 为什么卖**」,且数字全部来自引擎当时的计算:
> - `ActionRecord.reason`(`TradeReason{code,text,data}`)覆盖**成交与未成交**的每个买卖点:
> 名次 / 候选数 / 综合分 / 各因子**原始值** / 持有交易日 / 现金预算 / 涨停比值…;
> 原因分类是封闭词表(`quant/trade_reasons.py`),组合引擎与单策略引擎共用同一套构造器,
> 两个引擎对同一件事不会写出两种说法。
> - 区分「**跌出 TopN**」「**不在候选池**(被股票池/条件过滤,如转 ST)」「**全量换仓**」
> (单策略引擎每次调仓先清仓再建仓,被卖出的股票可能仍排在 TopN 内,这时不能写「跌出 TopN」)
> 「**Tmin 保护暂留**」「**超 Tmax 强制了结**」「涨停/停牌/现金不足/不足最低佣金」。
> - `result.factor_curves`:每个策略因子一条曲线 = 当日**持仓按市值加权平均的原始值**
> (不做 z-score、不按方向取反,**空仓日不落点、不插值、不用 0 填充**),带
> `label / direction / unit`,界面据此写明口径(如「股息率 %」。收益曲线对照成交日,
> 一眼看出「买在什么水平、卖在什么水平」)。
> - **每条曲线都能新页面放大**:`/charts/{归档id}?s={equity|drawdown|factor:<name>|sym:<code>|monthly}`。
> 放大页是 Server Component,数据从**归档**直出(URL 可分享、刷新还原同一张图);
> 结果没归档时不显示假按钮,而是写明「未归档,无法放大」。
> 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双),避免同一个综合分在理由原文与
> 数字标签里显示成两个数。
> **作业反馈(全站统一)**(2026-10):`lib/jobs.ts` 的 `useJobRunner` + `components/JobProgress.tsx`。
> 解决的问题是用户原话「点了回测没有任何反馈,不清楚是不是已经开始」:
> **提交瞬间**就显示「排队中 + 作业号 + 发起时间」,**已用时间每秒自增**(不等后端报新阶段),
> 阶段取后端真实 `stage`,排队/运行中可**取消任务**(`POST /jobs/{id}/cancel`),
> 失败显示后端原文、成功给「打开归档 / 去对比」;反馈条出现时自动滚入视野
> (运行按钮常在长表单底部,不滚过去等于没显示),并带 `role="status" aria-live="polite"`。
> 各页多个作业互不干扰(各自 `anchorId`)。
> 阶段小圆点**按作业类型分别定义**(`STAGE_PIPELINES`):因子测试是
> 「加载行情与因子数据 → 计算因子值 → 汇总指标与曲线」,回测/回测组合是
> 「加载行情与因子数据 → 撮合与净值结算 → 汇总指标与曲线」,异步选股只有
> 「逐择股日选股」一段 —— 后端没上报的阶段不会画成「○ 待开始」(那是看似有数据的假象),
> 后端新阶段的文案没跟上时也不会露出裸英文枚举(显示「执行中(stage)」并保留已推进的圆点)。
> `POST /api/signals` 是**同步**接口,页面如实写明「同步请求、没有作业号与阶段、不能取消」,
> 而不是给它编一个作业号。
**图表**:全站统一使用 **TradingView Lightweight Charts**(`components/charts/LwChart.tsx`),
ECharts 已从依赖中移除。买卖点标记(▲绿=买入 / ▼红=卖出)只落在该 series 真实存在的交易日上,
并按时间升序提交(Lightweight Charts 的硬约束),因此不会出现标记丢失或错位。
@@ -524,6 +558,16 @@ cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace
# 只验接口与页面(跳过回测 Job):加 --skip-job
```
**回测结果契约**(跑一次真实区间,约 3 分钟;校验页面实际读取的每个字段):
```bash
cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py --end 2023-12-31
```
它除了净值/个股曲线/买卖点落点,还会断言:**每个买卖点都有结构化理由且原因代码在词表内**、
成交类理由带名次/候选数/综合分/因子原始值、`trades` 两端理由齐全、`factor_curves` 非空且
日期升序无重复 —— 也就是「买了什么原因、图上的因子曲线」这些字段真的在。
#### 6.3.2 界面规范与对齐自检(控件尺寸 / 输入友好)
界面不是"能看就行":控件错位、尺寸与内容不匹配、点不中、报错说不清,都会直接变成操作错误。
@@ -625,12 +669,12 @@ Agent 能力边界(10 个内置受控工具,只读 + 受控写库):
```bash
cd backend
uv run ruff check app tests && uv run ruff format --check app tests
uv run pytest # 全量测试(每个里程碑提交前均须通过,当前 500 条)
uv run pytest # 全量测试(每个里程碑提交前均须通过,当前 524 条)
cd frontend/web
pnpm run typecheck # tsc --noEmit,0 error
pnpm run test:charts # 图表标记逻辑单测(7 条,node --test,无需额外依赖)
pnpm run build # 13 条路由,含 /strategies /backtest /experiments /experiments/[id]
pnpm run build # 路由含 /strategies /backtest /experiments /experiments/[id] /charts/[id]
```
**端到端契约脚本**(会真实提交回测 Job,用于验证「页面实现」与「后端字段」不漂移):
@@ -638,7 +682,7 @@ pnpm run build # 13 条路由,含 /strategies /backtest /e
```bash
cd backend
PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py # 选股策略/字段库/因子目录+参数化/公共配置/回测组合库/归档链路(145 项 skip-job,含真实组合回测更多,约 5 分钟)
PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约(约 4 分钟)
PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约:字段 + 买卖理由词表/名次/因子值 + 因子曲线(约 3 分钟)
python3 ../scripts/verify_ui_alignment.py # UI 对齐与控件一致性(8 个页面 × 2 宽度 = 160 项,约 3 分钟)
python3 ../scripts/verify_unit_conversion.py # 字段库单位:只能在给定范围内选 + 界面单位⇄基准单位换算(约 2 分钟)
python3 ../scripts/verify_factor_params.py # 因子参数化:暴露真实参数/界面新建参数化因子/越界拒绝/停用不影响历史解析(约 3 分钟)
@@ -651,7 +695,9 @@ python3 ../scripts/verify_factor_params.py # 因
归档、Agent 工具白名单与编排、LLM 配置加载、API 端到端、**策略说明推导**
(`describe_strategy` 的分支/互斥/缺失值语义)、**股票名称回填**(回测与选股两侧)、
**UI 对齐与控件一致性**(同排等高 / 尺寸归一 / 点击目标 / 标签 / 无障碍名 / 横向溢出,
7 个页面 × 1500 与 375px 两种视口,见 §6.3.2)。
7 个页面 × 1500 与 375px 两种视口,见 §6.3.2)、**买卖理由与因子曲线**
(理由词表封闭、数字来自引擎、因子曲线持仓市值加权且空仓不落点,见
`tests/test_trade_reasons.py` 8 条 + `tests/test_local_engine_reasons.py` 14 条)。
---
+62 -35
View File
@@ -18,11 +18,12 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "rea
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api";
import { STAGE_LABEL, runSavedCombo, submitComboJob, waitJob } from "@/lib/jobs";
import { runSavedCombo, submitComboJob, useJobRunner } from "@/lib/jobs";
import { recentRange } from "@/lib/dates";
import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types";
import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui";
import { BacktestResultView } from "@/components/BacktestResultView";
import { JobProgress } from "@/components/JobProgress";
import { adjustLabel } from "@/lib/labels";
export default function BacktestPage() {
@@ -61,9 +62,18 @@ function emptyDraft(range: { start: string; end: string }): Draft {
};
}
function draftToCombo(d: Draft): BacktestCombo {
/**
* 草稿 → 组合对象。
*
* `nameFallback`:**运行**(不保存)时组合名可以为空,但 `/combos/run` 的契约里
* `name` 必填 —— 老版本直接提交空名字,用户看到的是 422 原文
* (`String should have at least 1 character`),点了「运行」却像什么都没发生。
* 运行是临时组合、不会落库,所以这里给一个**如实说明「未命名/仅本次运行」**的名字,
* 而不是编一个像真的组合名。要正式命名请用「保存为回测组合」。
*/
function draftToCombo(d: Draft, nameFallback?: string): BacktestCombo {
return {
name: d.name.trim(),
name: d.name.trim() || nameFallback || "",
description: d.description.trim(),
strategy_ids: d.strategyIds,
initial_capital: d.initialCapital,
@@ -127,11 +137,17 @@ function BacktestInner() {
const [draft, setDraft] = useState<Draft>(emptyDraft(range));
const [savedComboId, setSavedComboId] = useState<string | null>(null);
const [result, setResult] = useState<BacktestResult | null>(null);
// 本次结果的归档 id:结果区据此提供「新页面放大」(放大页从归档读同一份数据)。
// 同步/未归档的结果没有 id,放大入口会如实说明原因而不是给个坏链接。
const [resultArchiveId, setResultArchiveId] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [runningComboId, setRunningComboId] = useState<string | null>(null);
const [jobId, setJobId] = useState("");
const [stage, setStage] = useState("");
const [elapsed, setElapsed] = useState(0);
/**
* 作业反馈统一交给 `useJobRunner`(全站同一套):提交瞬间就有「正在提交作业…」,
* 之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消 —— 用户反馈的
* 「点了回测没有任何反馈,不知道有没有开始」就是这里的缺口。
*/
const job = useJobRunner<BacktestResult>("回测组合");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [saving, setSaving] = useState(false);
@@ -222,7 +238,13 @@ function BacktestInner() {
});
return;
}
await runVia(() => submitComboJob(draftToCombo(draft)), null);
// 未命名时用「未命名组合(仅本次运行)」:临时组合不落库,只是给作业一个可读标签;
// 不让用户在点「运行」后撞上 422(那正是「点了没反应」的观感来源之一)。
const fallback = draft.name.trim() ? undefined : "未命名组合(仅本次运行)";
if (fallback) {
setNotice("本次运行未命名组合名 —— 按「未命名组合(仅本次运行)」提交(不会保存到组合库)。");
}
await runVia(() => submitComboJob(draftToCombo(draft, fallback)), null);
}
/**
@@ -237,20 +259,24 @@ function BacktestInner() {
setRunningComboId(comboId);
setError("");
setNotice("");
setJobId("");
setResult(null);
setResultArchiveId(null);
try {
const { job_id } = await submit();
setJobId(job_id);
setStage("queued");
const out = await waitJob<BacktestResult>(job_id, 900_000, (info) => {
setStage(info.stage);
setElapsed(info.elapsedMs);
const out = await job.run(submit, {
label: draft.name ? `回测组合「${draft.name}」` : "回测组合",
});
if (out.status === "success" && out.result) {
setResult(out.result);
setResult(out.result as BacktestResult);
const exp = out.experimentId ?? null;
setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。");
setResultArchiveId(exp);
setNotice(
exp
? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。`
: "回测完成,已自动归档。",
);
} else if (out.status === "cancelled") {
// 主动取消不是错误:如实说明,并提示可重新运行
setNotice("已取消该回测任务(未产生结果)。可改完参数后重新运行。");
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
@@ -259,7 +285,6 @@ function BacktestInner() {
} finally {
setRunning(false);
setRunningComboId(null);
setJobId("");
}
}
@@ -621,23 +646,19 @@ function BacktestInner() {
</div>
</div>
{running ? (
<div className="stages" style={{ marginTop: 12 }}>
{["data_loading", "backtesting", "analysis"].map((st) => {
const order = ["queued", "data_loading", "backtesting", "analysis", "done"];
const cur = order.indexOf(stage);
const mine = order.indexOf(st);
const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
return (
<span className={cls} key={st}>
{mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st}
</span>
);
})}
<span className="hint">
作业 <span className="mono">{jobId || "排队中…"}</span> · 已用 {Math.round(elapsed / 1000)}s
(全市场多年区间约 3~5 分钟,可离开本页,结果会归档)
</span>
{/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消。
kind="backtest":本页跑的是 combo 作业,但后端上报的阶段与 backtest 完全一致
(combo_service.py:68/70/90),所以共用同一条阶段序列。 */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="backtest-job-progress"
kind="backtest"
/>
{job.state.phase === "idle" && running ? (
<div className="hint" style={{ marginTop: 12 }} role="status" aria-live="polite">
正在提交作业…(提交成功后会显示作业号与真实阶段,可随时取消)
</div>
) : null}
</Card>
@@ -649,7 +670,13 @@ function BacktestInner() {
</Card>
) : null}
{result ? <BacktestResultView result={result} name={draft.name || "回测组合"} /> : null}
{result ? (
<BacktestResultView
result={result}
name={draft.name || "回测组合"}
archive={resultArchiveId ? { id: resultArchiveId } : undefined}
/>
) : null}
</>
);
}
+237
View File
@@ -0,0 +1,237 @@
/**
* 曲线放大页(`/charts/{归档id}?s={曲线}`)—— 「所有曲线都能弹出新页面看大的」。
*
* 设计取舍:
* - **Server Component**:数据从归档直出(URL 即快照地址,刷新/分享都还原同一张图);
* 曲线切换用 URL 参数(`?s=`),每个切换按钮就是一个 `<Link>`,不需要客户端状态。
* - **一页一曲线、尽可能大**:放大页只干一件事 —— 把一条曲线画大。因此高度直接给足
* (`CHART_HEIGHT`),并给出该曲线的口径说明与买卖点标注。
* - **数据只来自归档**:不重新跑回测、不从内存里取,避免「放大页与归档不一致」。
* - 归档不存在 → 404;归档类型没有该曲线 → 如实说明并给回归档详情页的入口。
*/
import { notFound } from "next/navigation";
import Link from "next/link";
import { LwChart } from "@/components/charts/LwChart";
import { CHART, fmtNum } from "@/components/charts/theme";
import { Card, Pill, Banner } from "@/components/ui";
import type { LwFormatKey } from "@/components/charts/LwChart";
import {
drawdownSeries,
equitySeries,
factorCurveNote,
factorFormatKey,
factorSeries,
monthlySeries,
portfolioMarkers,
symbolMarkers,
symbolSeries,
} from "@/lib/chartSeries";
import { experimentKindLabel } from "@/lib/labels";
import type { BacktestResult, ExperimentDetail } from "@/lib/types";
export const dynamic = "force-dynamic";
const BACKEND = (process.env.BACKEND_API_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "");
/** 放大页的主要目的就是「看大图」,给足高度(窄屏靠 CSS 缩到视口内) */
const CHART_HEIGHT = 640;
async function serverGet<T>(path: string): Promise<T | null> {
try {
const r = await fetch(`${BACKEND}/api${path}`, { cache: "no-store" });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
interface Option {
key: string;
label: string;
hint: string;
}
/** 该归档里所有可放大的曲线(顺序即推荐阅读顺序) */
function optionsFor(result: BacktestResult): Option[] {
const out: Option[] = [
{ key: "equity", label: "组合净值", hint: "含买卖点(成交日)" },
{ key: "drawdown", label: "回撤", hint: "距历史最高的回撤(%)" },
];
for (const f of result.factor_curves ?? []) {
out.push({ key: `factor:${f.name}`, label: `因子 · ${f.label}`, hint: "持仓加权平均原始值" });
}
for (const c of result.symbol_curves ?? []) {
out.push({ key: `sym:${c.symbol}`, label: `个股 · ${c.symbol}`, hint: "持仓期累计收益(%)" });
}
if ((result.monthly_returns ?? []).length) {
out.push({ key: "monthly", label: "月度收益", hint: "每月收益(%)" });
}
return out;
}
export default async function ChartPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ s?: string }>;
}) {
const { id: rawId } = await params;
const id = decodeURIComponent(rawId ?? "");
const sp = await searchParams;
const detail = await serverGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`);
if (!detail) notFound();
const result = detail.result as BacktestResult | null;
const isBacktest = Boolean(result && Array.isArray(result.equity_curve));
if (!isBacktest) {
return (
<Card icon="chartLine" title="该归档没有可放大的回测曲线">
<Banner tone="info">
归档 {id} 的类型是「{experimentKindLabel(detail.kind)}」,它的结果结构里没有净值 /
因子曲线(这些曲线只在回测归档里)。这不是错误,只是曲线放大页不适用于该类型。
</Banner>
<div style={{ marginTop: 10 }}>
<Link className="btn btn--sm" href={`/experiments/${encodeURIComponent(id)}`}>
<span>打开归档详情</span>
</Link>
</div>
</Card>
);
}
const res = result as BacktestResult;
const options = optionsFor(res);
const want = sp?.s ?? options[0]?.key ?? "equity";
const current = options.find((o) => o.key === want) ?? options[0];
const factorKey = current?.key.startsWith("factor:") ? current.key.slice("factor:".length) : null;
const symKey = current?.key.startsWith("sym:") ? current.key.slice("sym:".length) : null;
let series = equitySeries(res);
let markers = portfolioMarkers(
res.fills,
new Set(res.equity_curve.map((p) => p.date))
);
let formatKey: LwFormatKey = "num";
let note = "组合净值(元):每日收盘后按持仓市值结算;▲ 绿 = 当日有买入成交,▼ 红 = 当日有卖出成交。";
let zeroLine = false;
if (factorKey) {
const curve = (res.factor_curves ?? []).find((f) => f.name === factorKey);
if (curve) {
const idx = (res.factor_curves ?? []).findIndex((f) => f.name === factorKey);
series = [factorSeries(curve, idx)];
formatKey = factorFormatKey(curve);
note = factorCurveNote(curve);
// 因子曲线上的买卖点:把组合的成交日标在因子曲线上,直接看「买卖发生在什么水平」
markers = portfolioMarkers(res.fills, new Set(curve.points.map((p) => p.date)));
}
} else if (symKey) {
const curve = (res.symbol_curves ?? []).find((c) => c.symbol === symKey);
if (curve) {
series = symbolSeries(curve);
markers = symbolMarkers(curve);
formatKey = "pct2";
note =
`${curve.symbol} 持仓期间的累计收益率(%,以建仓日收盘为 0% 基准,按日复利),` +
"只在该股持仓的交易日落点;买卖点为实际成交。";
zeroLine = true;
}
} else if (current?.key === "drawdown") {
series = drawdownSeries(res);
markers = [];
formatKey = "pct2";
note = "回撤(%):净值相对历史最高点的跌幅,越负越深。";
zeroLine = true;
} else if (current?.key === "monthly") {
series = monthlySeries(res);
markers = [];
formatKey = "pct2";
note = "月度收益(%):每个月末相对上月末的净值变化。";
zeroLine = true;
}
const seriesLabel = series[0]?.label ?? current?.label ?? "曲线";
return (
<>
<div className="between" style={{ margin: "4px 0 12px", gap: 12, flexWrap: "wrap" }}>
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
<b style={{ fontSize: 16 }}>曲线放大</b>
<Pill tone="accent">{experimentKindLabel(detail.kind)}</Pill>
<span className="mono hint">{id}</span>
{detail.created_at ? (
<span className="hint">归档于 {String(detail.created_at).slice(0, 19).replace("T", " ")}</span>
) : null}
</div>
<div className="row" style={{ gap: 8 }}>
<Link className="btn btn--sm" href={`/experiments/${encodeURIComponent(id)}`}>
<span>打开归档详情</span>
</Link>
</div>
</div>
<div className="row" style={{ gap: 6, flexWrap: "wrap", marginBottom: 10 }}>
{options.map((o) => (
<Link
key={o.key}
className={o.key === current?.key ? "btn btn--sm btn--primary" : "btn btn--sm"}
href={`/charts/${encodeURIComponent(id)}?s=${encodeURIComponent(o.key)}`}
title={o.hint}
>
<span>{o.label}</span>
</Link>
))}
</div>
<Card
icon="chartLine"
title={seriesLabel}
tools={
<Pill tone={series[0]?.type === "bar" ? "violet" : "pos"}>
{series[0]?.data.length ?? 0} 个点
{markers.length ? ` · ${markers.length} 个买卖标注` : ""}
</Pill>
}
>
<LwChart
series={series}
markers={markers}
height={CHART_HEIGHT}
formatKey={formatKey}
zeroLine={zeroLine}
ariaLabel={`${seriesLabel}(放大)`}
/>
<div className="hint" style={{ marginTop: 8 }}>
{note} 拖动 / 滚轮可缩放,双击图例可临时隐藏曲线;地址栏 URL 可直接分享或收藏
(换设备打开还原同一张图,因为数据来自归档快照)。
</div>
</Card>
{res.summary ? (
<Card icon="gauge" title="这次回测的关键指标(与曲线同一份快照)">
<div className="row" style={{ gap: 16, flexWrap: "wrap" }}>
<span>
区间 <span className="mono">{res.summary.start}</span> ~{" "}
<span className="mono">{res.summary.end}</span>
</span>
<span>
总收益{" "}
<b className={res.summary.total_return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{res.summary.total_return_pct.toFixed(2)}%
</b>
</span>
<span>年化 {res.summary.annual_return_pct.toFixed(2)}%</span>
<span>Sharpe {res.summary.sharpe.toFixed(3)}</span>
<span>最大回撤 {res.summary.max_drawdown_pct.toFixed(2)}%</span>
<span>期末权益 {fmtNum(res.summary.final_equity)}</span>
<span className="hint" style={{ color: CHART.faint }}>
共 {res.summary.total_trades} 笔成交
</span>
</div>
</Card>
) : null}
</>
);
}
+509 -114
View File
@@ -11,8 +11,15 @@
* 微调时一眼看出「这次到底改了什么」。
*
* 曲线用 TradingView Lightweight Charts(全站统一图表基座)。
*
* 后续补上的三件事(都是「列表页该有的基本操作」):
* - **批量删除**:每行第一列的复选框(与「对比」那列刻意分开)攒出待删集合,
* 顶部工具条一次提交;后端按 id 逐个回报 `deleted` / `missing`,界面**照实**显示。
* - **测试发起时间列**:用 `created_at` 转本地时区渲染,并可点击表头排序。
* - **详情改成图层**:列表原地看结果,不再把用户从列表页带走;「打开归档」仍在
* 新标签页打开完整视图(需要 URL 可分享/可刷新时用它)。
*/
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { apiGet, apiGetWithHeaders, apiPost } from "@/lib/api";
@@ -26,13 +33,14 @@ import {
Btn,
Banner,
Empty,
Metric,
SkeletonLines,
Loading,
} from "@/components/ui";
import { Icon } from "@/components/icons";
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
import { CHART, seriesColor } from "@/components/charts/theme";
import { seriesColor } from "@/components/charts/theme";
import { Modal } from "@/components/Modal";
import { ArchiveResultView } from "@/components/ArchiveResultView";
/**
* 类型徽标的**色调**(措辞统一走 `lib/labels.ts::experimentKindLabel`)。
@@ -94,6 +102,40 @@ function shortDataVersion(v: string): string {
return v.length > 28 ? `${v.slice(0, 28)}…` : v;
}
/**
* 测试发起时间:后端给的是 ISO 字符串(本机时区的 naive datetime,如 `2026-10-01T17:45:05`)。
*
* 必须过一遍 `new Date(...)` 再取本地字段,**不能直接截字符串**:截字符串等于把后端
* 字符串里的时间当本地时间照搬,一旦后端改成带时区的 UTC(`...Z`)就会差 8 小时,
* 而且错得没有任何提示。由 Date 负责时区换算,格式异常时原样回显(不假装是时间)。
*/
function fmtLocalTime(v?: string | null): string {
if (!v) return "—";
const d = new Date(v);
if (Number.isNaN(d.getTime())) return v;
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(
d.getMinutes()
)}:${p(d.getSeconds())}`;
}
/** 后端批量删除单次最多接受的 id 数(`BULK_DELETE_MAX_IDS`);超了接口直接 422,一个都不删 */
const BULK_DELETE_MAX_IDS = 200;
/** 按上限切片:超过 200 个选中必须分批提交,否则整批被 422 拒掉 */
function chunkIds(ids: string[], size: number): string[][] {
const out: string[][] = [];
for (let i = 0; i < ids.length; i += size) out.push(ids.slice(i, i + size));
return out;
}
/** 批量删除的响应(后端语义:存在的进 deleted,库里没有的如实进 missing,绝不静默吞掉) */
interface BulkDeleteResult {
deleted: string[];
missing: string[];
count: number;
}
const METRICS: { key: keyof BacktestResult["summary"]; label: string; fmt: (v: number) => string; better?: "high" | "low" }[] = [
{ key: "total_return_pct", label: "区间收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
{ key: "annual_return_pct", label: "年化收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
@@ -118,15 +160,52 @@ export default function ExperimentsPage() {
);
}
/**
* 宽表此刻是否需要横向滚动(按实测 `scrollWidth > clientWidth`,容器尺寸变化时重算)。
*
* 为什么要提示:卡片内的横向滚动条在 macOS 上是覆盖式的(不滚就不显示),
* 用户看到的就是「右侧内容被切掉了」——这正是「experiments 页面右侧内容溢出了」的来源。
* 为什么要在运行时判断而不是写死一句提示:列宽固定,窗口够宽时根本不需要滚动,
* 那种情况下还挂着提示就是废话(还让人以为页面坏了)。
*/
function useTableScrollHint(deps: React.DependencyList) {
const ref = useRef<HTMLDivElement | null>(null);
const [scrolls, setScrolls] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const check = () => setScrolls(el.scrollWidth > el.clientWidth + 1);
check();
const ro = new ResizeObserver(check);
ro.observe(el);
return () => ro.disconnect();
// deps 由调用方给(行数 / 筛选变化时要重新量)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { ref, scrolls };
}
function ExperimentsInner() {
const search = useSearchParams();
const [exps, setExps] = useState<ExperimentMeta[]>([]);
const [loading, setLoading] = useState(true);
// 列表容器:窄窗口下宽表要横向滚动,提示只在真需要时出现
const { ref: tableWrapRef, scrolls: tableScrolls } = useTableScrollHint([exps.length, loading]);
const [detail, setDetail] = useState<ExperimentDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState("");
/** 图层是否打开(打开的是哪一条)—— detail 本身可能还在路上,所以不能只看 detail */
const [detailId, setDetailId] = useState<string | null>(null);
/** 打开图层的触发按钮:关闭后焦点要还给它(键盘用户不能"掉回页首") */
const [detailTrigger, setDetailTrigger] = useState<HTMLElement | null>(null);
const [jobId, setJobId] = useState("");
const [jobStatus, setJobStatus] = useState("");
const [error, setError] = useState("");
/**
* 操作结果提示(删除回执等):与 error 分开 —— 红色横幅只留给真正的失败。
* tone 显式存下来,而不是在渲染时用文案里有没有"不存在"去猜(文案一改就会猜错)。
*/
const [notice, setNotice] = useState<{ text: string; tone: "info" | "warn" } | null>(null);
// 对比状态
const [picked, setPicked] = useState<string[]>([]);
@@ -134,6 +213,16 @@ function ExperimentsInner() {
const [comparing, setComparing] = useState(false);
const [onlyDiff, setOnlyDiff] = useState(true);
// 批量删除的选择:与 picked(对比,上限 3 个)**刻意分开**——
// 复用同一个集合的话「全选本页」会被对比上限卡住,而且删除是不可逆动作,
// 不该和「我想比一比」共用一次勾选(误删代价太大)。
const [toDelete, setToDelete] = useState<string[]>([]);
const [deleting, setDeleting] = useState(false);
// 列表排序:默认按发起时间倒序(与后端 list_filtered 的 created_at desc 一致),
// 点表头在升降之间切换。
const [timeSort, setTimeSort] = useState<"desc" | "asc">("desc");
// 列表过滤:后端支持 kind/q(并把过滤后总数放在 X-Total-Count 响应头)。
// 初始值取自 URL(`?q=`/`?kind=`),且每次改动**回写 URL**:筛选条件可分享、刷新不丢,
// 也让「当前看到的是哪一批」有据可查(而不是只存在于组件状态里)。
@@ -186,13 +275,113 @@ function ExperimentsInner() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function open(id: string) {
setDetailLoading(true);
/**
* 详情加载序号:用户可能连点两行的「详情」,先发的请求后到会把 A 的内容
* 盖到 B 的图层上(旧代码原地渲染时同样有这个竞态)。每次打开自增,回调里
* 只认最后一次,迟到的一律丢弃。
*/
const detailSeq = useRef(0);
/**
* 打开详情图层:失败**原文**显示在后端错误里(不吞成"加载失败"),
* 因为这里通常能直接看出是 404(已删)/ 500 还是网络问题。
*/
function openDetail(id: string, trigger: HTMLElement | null) {
const seq = ++detailSeq.current;
setDetailTrigger(trigger);
setDetailId(id);
setDetail(null);
apiGet<ExperimentDetail>(`/experiments/${id}`)
.then((d) => setDetail(d))
.catch((e: Error) => setError(e.message))
.finally(() => setDetailLoading(false));
setDetailError("");
setDetailLoading(true);
apiGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`)
.then((d) => {
if (seq === detailSeq.current) setDetail(d);
})
.catch((e: Error) => {
if (seq === detailSeq.current) setDetailError(e.message);
})
.finally(() => {
if (seq === detailSeq.current) setDetailLoading(false);
});
}
function closeDetail() {
// 自增序号:关闭之后迟到的响应不能再把图层"顶"回来
detailSeq.current += 1;
setDetailId(null);
setDetail(null);
setDetailError("");
setDetailLoading(false);
}
/** 单行「删除选择」勾选:不设上限——批量删除本来就要跨多行选 */
function toggleDelete(id: string) {
setNotice(null);
setToDelete((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
/** 批量删除:一次最多 200 个 id,超了前端分批,最后把各批的 deleted/missing 合并上报 */
async function removeSelected() {
if (!toDelete.length || deleting) return;
const ids = [...toDelete];
const n = ids.length;
const ok = window.confirm(
`将永久删除 ${n} 个归档,不可恢复。\n\n` +
`· 结果只存归档这一份:删除后这些回测/因子测试/选股的净值曲线、成交与 spec 都无法再查看,也无法导出;\n` +
`· 关联的作业记录会保留,但结果读不回(接口会如实标注"结果已随归档删除");\n` +
`· 想留底请先用每行「打开归档」进详情页导出完整 JSON。\n\n确认删除这 ${n} 个归档?`
);
if (!ok) return;
setDeleting(true);
setError("");
setNotice(null);
const deleted: string[] = [];
const missing: string[] = [];
try {
for (const batch of chunkIds(ids, BULK_DELETE_MAX_IDS)) {
const r = await apiPost<BulkDeleteResult>("/experiments/bulk-delete", { ids: batch });
deleted.push(...r.deleted);
missing.push(...r.missing);
}
} catch (e) {
// 分批提交时前面几批**已经真删了**:必须把"已删几个"说出来,
// 否则用户会以为一个都没删(或以为全删了)——两种都是谎报。
setError(`批量删除未全部完成:已删除 ${deleted.length} 个,之后失败 —— ${(e as Error).message}`);
} finally {
setDeleting(false);
// 只清掉「确实删掉了」与「本来就不存在」的选中项;因网络/接口失败没删成的仍留在
// 选中里,用户可直接重试,不用重新勾一遍。
const gone = new Set([...deleted, ...missing]);
setToDelete((prev) => prev.filter((id) => !gone.has(id)));
// 正开着的详情/对比里若含已删归档,必须一起清掉,否则会继续展示一份已不存在的快照。
// 用 detailId 而不是 detail 判定:详情可能还在加载(detail 还是 null),
// 这时请求回来仍会把一份已删的快照渲染进图层。
if (detailId && gone.has(detailId)) closeDetail();
setCompare((prev) => {
if (!prev) return prev;
const left = prev.filter((r) => !gone.has(r.id));
if (left.length === prev.length) return prev;
// 剩不到 2 条就没有"对比"可言了:关掉对比区,而不是画一条什么都比不出来的曲线
return left.length >= 2 ? left : null;
});
setPicked((prev) => prev.filter((id) => !gone.has(id)));
if (deleted.length || missing.length) {
// 照实报告:missing 非空必须显示("不存在的 id 也算删成功"会让用户以为库里干净了)
setNotice({
text:
`已删除 ${deleted.length} 个归档` +
(missing.length
? `,${missing.length} 个不存在(可能已被别处删掉,未计入删除数)`
: "") +
"。",
// missing 非空用警示色:不能让"有 id 没找到"被当成完全成功一眼扫过去
tone: missing.length ? "warn" : "info",
});
}
load();
}
}
function rerun(exp: ExperimentMeta) {
@@ -218,7 +407,8 @@ function ExperimentsInner() {
if (j.status === "success" || j.status === "failed" || tries > 60) {
window.clearInterval(timer);
load();
setDetail(null);
// 复跑会新增归档;图层里那份可能是刚被替换的旧快照,关掉避免看串
closeDetail();
}
})
.catch(() => window.clearInterval(timer));
@@ -258,7 +448,47 @@ function ExperimentsInner() {
}
}
const s = detail && isBacktestDetail(detail) ? detail.result.summary : undefined;
/**
* 展示用的行序:按「测试发起时间」升/降序排列(表头可切换)。
*
* 两个细节:
* - `created_at` 缺失的归档一律排在**末尾**,不参与升降 —— 让「—」混在时间中间会
* 让人以为那是一条很新/很旧的归档,那是假信息;
* - 同秒(MySQL datetime(0) 只有秒精度)用 id 兜底,方向与时间一致,
* 否则等长时间的行序会随机抖动(也和后端 created_at desc, id desc 的口径一致)。
*/
const rows = useMemo(() => {
const ts = (e: ExperimentMeta) => (e.created_at ? new Date(e.created_at).getTime() : NaN);
const byId = (a: ExperimentMeta, b: ExperimentMeta) =>
timeSort === "desc" ? (a.id < b.id ? 1 : a.id > b.id ? -1 : 0) : a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
return [...exps].sort((a, b) => {
const ta = ts(a);
const tb = ts(b);
const aBad = Number.isNaN(ta);
const bBad = Number.isNaN(tb);
if (aBad && bBad) return byId(a, b);
if (aBad) return 1;
if (bBad) return -1;
if (ta !== tb) return timeSort === "desc" ? tb - ta : ta - tb;
return byId(a, b);
});
}, [exps, timeSort]);
const pageIds = rows.map((e) => e.id);
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => toDelete.includes(id));
/**
* 表头「全选本页」:只作用于**当前显示**的这些行。
* 列表可能有后端单页上限(total > rows.length),"全选筛选结果"会把看不见的行也删掉,
* 那是拿用户没看过的数据冒险,所以这里不做,并在工具条里说明范围。
*/
function toggleAllOnPage() {
setNotice(null);
setToDelete((prev) => {
if (allOnPageSelected) return prev.filter((id) => !pageIds.includes(id));
return Array.from(new Set([...prev, ...pageIds]));
});
}
return (
<>
@@ -276,19 +506,56 @@ function ExperimentsInner() {
{error ? <Banner tone="error">{error}</Banner> : null}
{picked.length > 0 ? (
{/* 删除回执等操作结果:missing 非空时是 warn 色,避免"部分不存在"被当成完全成功 */}
{notice ? <Banner tone={notice.tone}>{notice.text}</Banner> : null}
{picked.length > 0 || toDelete.length > 0 ? (
<div className="sticky-bar">
<Pill tone="violet" icon="layers">
已选 {picked.length} 个
</Pill>
<span className="hint">{picked.join(" · ")}</span>
<Btn variant="primary" icon="chartLine" loading={comparing} disabled={comparing || picked.length < 2} onClick={runCompare}>
对比选中的实验
</Btn>
<Btn icon="x" onClick={() => { setPicked([]); setCompare(null); }}>
清空
</Btn>
{picked.length < 2 ? <span className="hint">再选 1 个即可对比</span> : null}
{toDelete.length > 0 ? (
<>
<Pill tone="neg" icon="trash">
已选 {toDelete.length} 个(待删除)
</Pill>
<Btn
variant="danger"
icon="trash"
loading={deleting}
disabled={deleting}
onClick={removeSelected}
>
{deleting ? "删除中…" : "删除所选"}
</Btn>
<Btn icon="x" disabled={deleting} onClick={() => setToDelete([])}>
取消选择
</Btn>
<span className="hint">
范围:当前列表显示的本页 {rows.length} 条
{total !== null && total > rows.length
? `(筛选结果共 ${total} 条,本页之外的未列出、也不会被删)`
: ""}
;删除不可恢复。
</span>
</>
) : null}
{picked.length > 0 ? (
<>
{toDelete.length > 0 ? (
/* 两组选择同时在时用一条分隔线隔开,否则会看成一整排互相矛盾的按钮 */
<span aria-hidden style={{ width: 1, alignSelf: "stretch", background: "var(--line)" }} />
) : null}
<Pill tone="violet" icon="layers">
已选 {picked.length} 个(对比)
</Pill>
<span className="hint">{picked.join(" · ")}</span>
<Btn variant="primary" icon="chartLine" loading={comparing} disabled={comparing || picked.length < 2} onClick={runCompare}>
对比选中的实验
</Btn>
<Btn icon="x" onClick={() => { setPicked([]); setCompare(null); }}>
清空
</Btn>
{picked.length < 2 ? <span className="hint">再选 1 个即可对比</span> : null}
</>
) : null}
</div>
) : null}
@@ -311,11 +578,21 @@ function ExperimentsInner() {
icon="archive"
title="实验列表"
sub={
total !== null
? `显示 ${exps.length} 条 / 共 ${total} 条${
total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : ""
}`
: "勾选左侧方框可选 2~3 个做对比"
<span>
{total !== null
? `显示 ${exps.length} 条 / 共 ${total} 条${
total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : ""
}`
: "第一列勾选可批量删除;「对比」列勾选 2~3 个可叠加曲线对比"}
{tableScrolls ? (
// 只在真的放不下时才说:卡片内的横向滚动条容易看不见(触控板尤其),
// 而右端「操作」列已固定,用户不必先滚到底才知道能点哪里。
<>
{" "}
<span className="hint">列较多,可横向滚动;选择列与「操作」列固定不动。</span>
</>
) : null}
</span>
}
tools={
<div className="row" style={{ gap: 8 }}>
@@ -372,27 +649,73 @@ function ExperimentsInner() {
hint="在「选股回测」或「因子研究」中运行一次,即会自动归档于此。"
/>
) : (
<div className="table-wrap">
<table className="tbl">
<div className="table-wrap table-wrap--wide" ref={tableWrapRef}>
<table className="tbl tbl--wide">
<thead>
<tr>
<th style={{ width: 36 }} aria-label="选择对比" />
{/* 第一列:批量删除的选择(表头是全选本页)。与「对比」列分开,
因为两者用途/上限完全不同(删除可多选且不可逆,对比最多 3 个)。 */}
<th
title={
total !== null && total > rows.length
? `全选本页(本页 ${rows.length} 个;筛选结果共 ${total} 个,本页之外的未列出,不会被删)`
: `全选本页(${rows.length} 个)`
}
>
<label className="check check--cell">
<input
type="checkbox"
checked={allOnPageSelected}
onChange={toggleAllOnPage}
aria-label={`全选本页 ${rows.length} 个归档用于删除`}
/>
</label>
</th>
<th>
<span className="hint" title="勾选这一列可挑 2~3 个实验做对比">
对比
</span>
</th>
<th>ID</th>
<th>类型</th>
<th>
{/* 排序交互沿用全站既有的表头按钮风格(见 TradeReasons 的日期列) */}
<button
type="button"
className="th-sort"
onClick={() => setTimeSort((d) => (d === "desc" ? "asc" : "desc"))}
title="按测试发起时间排序,点击切换升序/降序(空值始终排在最后)"
aria-label={`按测试发起时间排序:当前${timeSort === "desc" ? "降序" : "升序"},点击切换`}
>
发起时间 {timeSort === "desc" ? "↓" : "↑"}
</button>
</th>
<th>因子</th>
<th>区间</th>
<th>摘要</th>
<th>版本(代码 / 数据)</th>
<th>体积</th>
<th style={{ width: 260, textAlign: "right" }}>操作</th>
{/* 列宽由 .tbl--wide 统一给(含固定右端):行内 width 会盖掉 CSS,
曾经就是它让「操作」列保持 260px、整表多出 88px 放不下 */}
<th>操作</th>
</tr>
</thead>
<tbody>
{exps.map((e) => (
<tr key={e.id} className={detail?.id === e.id ? "row-active" : undefined}>
{rows.map((e) => (
<tr key={e.id} className={detailId === e.id ? "row-active" : undefined}>
<td>
{/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */}
<label className="check check--cell" title={`选择 ${e.id} 用于对比`}>
<label className="check check--cell" title={`选择 ${e.id} 用于批量删除`}>
<input
type="checkbox"
checked={toDelete.includes(e.id)}
onChange={() => toggleDelete(e.id)}
aria-label={`选择 ${e.id} 用于批量删除`}
/>
</label>
</td>
<td>
<label className="check check--cell" title={`选择 ${e.id} 用于对比(最多 3 个)`}>
<input
type="checkbox"
checked={picked.includes(e.id)}
@@ -401,8 +724,13 @@ function ExperimentsInner() {
/>
</label>
</td>
<td className="cell-mono cell-strong">{e.id}</td>
<td className="cell-mono cell-strong" title={e.id}>
{e.id}
</td>
<td>{kindView(e.kind)}</td>
<td className="cell-mono" title={e.created_at ?? "该归档没有记录发起时间"}>
{fmtLocalTime(e.created_at)}
</td>
<td>
<span className="row" style={{ gap: 4 }}>
{e.factors.map((f) => (
@@ -412,7 +740,12 @@ function ExperimentsInner() {
))}
</span>
</td>
<td className="cell-mono">{e.period ? `${e.period[0]}~${e.period[1]}` : "—"}</td>
<td
className="cell-mono"
title={e.period ? `区间 ${e.period[0]} ~ ${e.period[1]}` : "该归档没有记录区间"}
>
{e.period ? `${e.period[0]} ~ ${e.period[1]}` : "—"}
</td>
<td className="hint">{e.summary_text ?? "—"}</td>
<td>
<span className="row" style={{ gap: 4 }}>
@@ -435,10 +768,22 @@ function ExperimentsInner() {
</td>
<td style={{ textAlign: "right" }}>
<span className="row" style={{ justifyContent: "flex-end", gap: 6 }}>
<Link href={`/experiments/${encodeURIComponent(e.id)}`} className="btn btn--sm">
{/* 完整归档是"可分享、可刷新"的只读视图,用新标签页打开,
列表页的筛选/选择状态原地不动(看完关掉标签页就回来)。 */}
<a
href={`/experiments/${encodeURIComponent(e.id)}`}
className="btn btn--sm"
target="_blank"
rel="noreferrer"
>
<span>打开归档</span>
</Link>
<Btn size="sm" icon="search" onClick={() => open(e.id)}>
</a>
<Btn
size="sm"
icon="search"
// 把触发按钮传进图层:关闭后焦点要回到它(键盘用户不"掉回页首")
onClick={(ev) => openDetail(e.id, ev.currentTarget)}
>
详情
</Btn>
<Link href={`/backtest?from_experiment=${encodeURIComponent(e.id)}`} className="btn btn--sm">
@@ -463,83 +808,126 @@ function ExperimentsInner() {
)}
</Card>
{detail ? (
<Card
icon="book"
title={`${detail.id} · ${detail.factors.join("、")}`}
sub={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : undefined}
{/* 详情图层:原地看归档,不再把用户从列表带走(列表的筛选/选择/滚动位置都留着)。
「选股条件 / 交易执行依据」那部分说明由后端 describe_strategy 依归档 spec 推导
(归档详情页是 Server Component,在服务端调用它)—— 图层里不重复这份推导,
也不自行拼造文案;要看它就点标题栏的「在新页面打开完整归档」。 */}
{detailId ? (
<Modal
title={
<>
{detailId} 归档详情
{detail ? ` · ${experimentKindLabel(detail.kind)}` : ""}
</>
}
onClose={closeDetail}
returnFocusTo={detailTrigger}
tools={
<div className="row" style={{ gap: 8 }}>
<Link href={`/experiments/${encodeURIComponent(detail.id)}`} className="btn btn--sm">
<span>打开归档(完整视图)</span>
</Link>
<Btn size="sm" onClick={() => setDetail(null)}>
<Icon name="x" size={13} />
关闭
</Btn>
</div>
<a
className="btn btn--sm"
href={`/experiments/${encodeURIComponent(detailId)}`}
target="_blank"
rel="noreferrer"
>
<span>在新页面打开完整归档</span>
</a>
}
>
{detailLoading ? (
<SkeletonLines n={3} />
) : (
{detailError ? (
/* 后端错误原文照贴:404(已被删)和 500(后端异常)该做的事完全不同,
只写「加载失败」等于把可排查的信息丢掉。 */
<Banner tone="error">详情加载失败:{detailError}</Banner>
) : detailLoading ? (
<>
{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 && isBacktestDetail(detail) && detail.result.equity_curve.length ? (
<div style={{ marginTop: 12 }}>
<LwChart
series={[
{
key: "eq",
label: "累计收益率(%)",
type: "area",
color: CHART.accent,
data: normalize(detail.result),
lastValueVisible: true,
},
]}
height={260}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel={`${detail.id} 累计收益率曲线`}
/>
</div>
) : null}
{detail.spec ? (
<details style={{ marginTop: 12 }}>
<summary className="hint" style={{ cursor: "pointer" }}>
查看 spec(可复现输入)
</summary>
<pre className="mono" style={{ whiteSpace: "pre-wrap", fontSize: 12 }}>
{JSON.stringify(detail.spec, null, 2)}
</pre>
</details>
) : null}
<div className="hint" style={{ marginBottom: 8 }}>
正在读取归档 {detailId}…
</div>
<SkeletonLines n={5} />
</>
) : detail ? (
<>
<div className="kv-grid">
<MetaKV label="实验 ID" value={detail.id} mono />
<MetaKV label="类型" value={experimentKindLabel(detail.kind)} />
<MetaKV label="测试发起时间" value={fmtLocalTime(detail.created_at)} mono />
<MetaKV label="代码版本" value={detail.code_version ?? "—"} mono />
<MetaKV
label="数据版本"
value={detail.data_version ?? "未记录(该归档早于数据指纹上线)"}
mono
warn={!detail.data_version}
/>
<MetaKV label="来源作业" value={detail.job_id ?? "同步接口调用(无作业 id)"} mono />
<MetaKV
label="区间"
value={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : "—"}
mono
/>
<MetaKV
label="归档体积"
value={detail.result_bytes ? `${(detail.result_bytes / 1024).toFixed(0)} KB` : "—"}
/>
</div>
<div className="hint" style={{ margin: "10px 0 14px" }}>
结果区与归档页用的是同一个 <code>ArchiveResultView</code>(按 kind 分发),
所以回测的净值/成交、因子测试的 IC、选股的候选明细都在这里;
「选股条件 / 交易执行依据」这类需要后端依 spec 推导的说明只在完整归档页展示。
</div>
{detail.result ? (
<ArchiveResultView
kind={detail.kind}
result={detail.result}
archive={{
id: detail.id,
created_at: detail.created_at,
code_version: detail.code_version,
data_version: detail.data_version,
job_id: detail.job_id,
}}
/>
) : (
<Empty
icon="archive"
title="该归档没有可展示的结果"
hint="结果为空(可能是历史版本归档)。可用标题栏的「在新页面打开完整归档」,在那边「导出完整 JSON」查看原始内容。"
/>
)}
</>
) : (
<div className="hint">详情为空 —— 这条归档可能刚被删除,请刷新列表核对。</div>
)}
</Card>
</Modal>
) : null}
</>
);
}
/**
* 图层里的元数据行:沿用归档详情页同一套 `kv` 样式。
*
* 字段与取值口径跟归档页对齐(同一份归档在两个入口看到的 id/版本/作业 id/时间必须一致,
* 否则用户会怀疑哪个是真的);文案上列表与图层统一叫「测试发起时间」,归档页沿用
* 既有的「归档时间」—— 同一个 `created_at`,不在这里改归档页的既有措辞。
*/
function MetaKV({
label,
value,
mono,
warn,
}: {
label: string;
value: string;
mono?: boolean;
warn?: boolean;
}) {
return (
<div className="kv">
<span className="kv__k">{label}</span>
<span className={`kv__v${mono ? " mono" : ""}${warn ? " kv__v--warn" : ""}`}>{value}</span>
</div>
);
}
/* ------------------------------------------------------------------ */
/** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */
@@ -581,6 +969,11 @@ function CompareView({
setOnlyDiff: (v: boolean) => void;
onClose: () => void;
}) {
// 指标对比 / 参数差异两张表都是「一行一个指标 + 每个实验一列」:列数随对比个数增长,
// 窄窗口必然横向滚动。首列(指标名 / 参数名)必须固定,否则滚到右边不知道这一行是什么。
const metricTable = useTableScrollHint([rows.length]);
const paramTable = useTableScrollHint([rows.length, onlyDiff]);
const series = useMemo<LwSeries[]>(
() =>
rows.map((r, i) => ({
@@ -635,12 +1028,12 @@ function CompareView({
ariaLabel="实验净值曲线叠加对比"
/>
<div className="hint" style={{ marginTop: 6 }}>
曲线已归一化为**累计收益率 %**(各自以首个净值为 0%),因此初始资金不同也能直接比。
曲线已归一化为「累计收益率 %」(各自以首个净值为 0%),因此初始资金不同也能直接比。
点击图例可临时隐藏某条曲线(只算收益差的实验建议隐藏基准再比)。
</div>
<div className="table-wrap" style={{ marginTop: 14 }}>
<table className="tbl">
<div className="table-wrap" style={{ marginTop: 14 }} ref={metricTable.ref}>
<table className="tbl tbl--pin-first">
<thead>
<tr>
<th>指标</th>
@@ -685,18 +1078,19 @@ function CompareView({
差额列 = 最后一个实验 − 基准({rows[0].id})。✅ 表示该指标改善方向,⚠️ 表示变差
(收益/夏普/胜率越高越好,回撤/波动越低越好)。基准区间收益{" "}
{base.total_return_pct.toFixed(2)}%。
{metricTable.scrolls ? " 列较多,可横向滚动;首列「指标」固定不动。" : ""}
</div>
<div style={{ marginTop: 16 }}>
<b style={{ fontSize: 13 }}>参数差异(config_snapshot 逐项对比)</b>
{diffRows.length === 0 ? (
<div className="hint" style={{ marginTop: 6 }}>
这几个实验的参数快照完全一致(说明结果是**同一配置**下的数据变动,或只是复跑)。
这几个实验的参数快照完全一致(说明结果是「同一配置」下的数据变动,或只是复跑)。
已排除 price_basis.* 运行元数据。
</div>
) : (
<div className="table-wrap" style={{ marginTop: 6 }}>
<table className="tbl">
<div className="table-wrap" style={{ marginTop: 6 }} ref={paramTable.ref}>
<table className="tbl tbl--pin-first">
<thead>
<tr>
<th>参数</th>
@@ -725,9 +1119,10 @@ function CompareView({
</table>
</div>
)}
{onlyDiff ? (
{onlyDiff || paramTable.scrolls ? (
<div className="hint" style={{ marginTop: 6 }}>
已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。
{onlyDiff ? "已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。" : null}
{paramTable.scrolls ? " 参数表列较多,可横向滚动;首列「参数」固定不动。" : null}
</div>
) : null}
</div>
+62 -100
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { apiGet } from "@/lib/api";
import { submitJob, waitJob } from "@/lib/jobs";
import { submitJob, useJobRunner } from "@/lib/jobs";
import { factorLabel, pickableFactors } from "@/lib/factors";
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
import { recentRange } from "@/lib/dates";
@@ -13,14 +13,10 @@ import {
Field,
Btn,
Banner,
Progress,
Signed,
BacktestMetrics,
MonthlyReturnsTable,
UnimplementedNote,
} from "@/components/ui";
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
import { CHART, fmtNum, fmtPct } from "@/components/charts/theme";
import { BacktestResultView } from "@/components/BacktestResultView";
import { JobProgress } from "@/components/JobProgress";
import { Icon } from "@/components/icons";
interface Pick {
@@ -37,8 +33,18 @@ export default function ComposePage() {
const [start, setStart] = useState("");
const [end, setEnd] = useState("");
const [result, setResult] = useState<BacktestResult | null>(null);
const [running, setRunning] = useState(false);
const [jobId, setJobId] = useState("");
/** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */
const [archiveId, setArchiveId] = useState<string | null>(null);
/**
* 作业反馈统一交给 `useJobRunner`(与 /backtest 同一套):点了就有「正在提交作业…」,
* 之后是后端真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消。
* 此前这里只有一句「任务 JOB-xxx 后台执行中」的假进度条(value 固定 30),
* 用户既不知道提交成功没、也不知道跑到哪一步。
*/
const job = useJobRunner<BacktestResult>("因子组合回测");
const active =
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
/** 只留「参数不合法 / 目录加载失败」这类**作业之外**的错误;作业自身的失败由反馈条显示后端原文 */
const [error, setError] = useState("");
useEffect(() => {
@@ -84,33 +90,27 @@ export default function ComposePage() {
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<BacktestResult>(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("");
setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果
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 out = await job.run(() => submitJob(spec), {
label: `因子组合回测(${valid.length} 个因子)`,
});
if (out.status === "success" && out.result) {
setResult(out.result);
// 归档 id 交给结果区做「新页面放大」;反馈条自己也会给「打开归档 / 去对比」
setArchiveId(out.experimentId ?? null);
}
// 失败 / 超时 / 取消都只在反馈条上说:后端原文(out.error)、
// 「已取消(不产生归档)」都在 JobProgress 里,页面不再另写一份 Banner 重复同一个失败
}
return (
@@ -266,30 +266,32 @@ export default function ComposePage() {
<Btn
variant="primary"
icon="play"
loading={running}
disabled={running || picks.length === 0}
loading={active}
disabled={active || picks.length === 0}
onClick={run}
>
{running ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
{active ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
</Btn>
</div>
{running ? (
<div style={{ marginTop: 14 }}>
<Progress
value={30}
label={
<>
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,完成后结果自动展示在本页。
</>
}
/>
</div>
) : null}
{/* 反馈条紧贴运行按钮:提交瞬间就有字,排队阶段就能取消,完成后给归档入口 */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="factors-compose-job-progress"
kind="backtest"
/>
{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}
{result ? (
<ResultView
result={result}
params={{ picks, topN, rebalance, excludeSt, start, end }}
archiveId={archiveId}
/>
) : null}
</>
);
}
@@ -297,36 +299,17 @@ export default function ComposePage() {
function ResultView({
result,
params,
archiveId,
}: {
result: BacktestResult;
params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string };
archiveId?: string | null;
}) {
const s = result.summary;
// 曲线数据点:LwChart 的 time 用 "YYYY-MM-DD" 字符串,向后端 CurvePoint 的 date 直接映射
const equitySeries: LwSeries[] = [
{
key: "equity",
label: "净值",
type: "area",
color: CHART.pos,
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
},
];
const drawdownSeries: LwSeries[] = [
{
key: "drawdown",
label: "回撤",
type: "line",
color: CHART.neg,
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
},
];
return (
<>
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row">
<div className="row" style={{ flexWrap: "wrap", gap: 8 }}>
<Pill tone="pos" icon="check">
回测完成
</Pill>
@@ -334,41 +317,20 @@ function ResultView({
<Pill>{params.rebalance === "monthly" ? "月度调仓" : "周度调仓"}</Pill>
{params.excludeSt ? <Pill>剔除 ST</Pill> : null}
<Pill>{params.start} ~ {params.end}</Pill>
<Pill>{params.picks.length} 个因子</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>}>
<LwChart
series={equitySeries}
height={300}
valueFormat={(v) => fmtNum(v, 2)}
ariaLabel="净值曲线"
emptyHint="该区间没有净值数据"
/>
</Card>
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
<LwChart
series={drawdownSeries}
height={300}
zeroLine
valueFormat={(v) => fmtPct(v, 2)}
ariaLabel="回撤曲线"
emptyHint="该区间没有回撤数据"
/>
</Card>
</div>
<Card icon="calendar" title="月度收益(%)">
<MonthlyReturnsTable rows={result.monthly_returns} />
</Card>
<UnimplementedNote items={result.unimplemented} />
{/* 与「回测组合」共用同一个结果视图:买卖理由、因子曲线、放大入口都在那里,
两处各写一份迟早出现「同一个结果两套图」的口径漂移 */}
<BacktestResultView
result={result}
name="因子组合"
archive={archiveId ? { id: archiveId } : undefined}
/>
</>
);
}
+55 -60
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { apiGet, apiPatch, apiPost } from "@/lib/api";
import { submitJob, waitJob } from "@/lib/jobs";
import { submitJob, useJobRunner } from "@/lib/jobs";
import type {
FactorMeta,
FactorParam,
@@ -19,10 +19,10 @@ import {
Field,
Btn,
Banner,
Progress,
Empty,
SkeletonLines,
} from "@/components/ui";
import { JobProgress } from "@/components/JobProgress";
/**
* 因子研究页。
@@ -180,10 +180,15 @@ export default function FactorsPage() {
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("");
/**
* 作业反馈统一交给 `useJobRunner`:本页是「一个因子一个 Job」的批量场景,
* 每轮把 (第 i/共 n 个) 作为 progress 交给反馈条 —— 阶段、作业号、已用秒数全部来自后端,
* 不再由前端按 processed/selected 算一个百分比假进度条(用户看不出到底跑到哪了)。
*/
const job = useJobRunner<FactorTestReport>("单因子测试");
/** 反馈条是否处于「跑着」的状态:用于禁用按钮、让位给反馈条的提示位。 */
const active =
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
const [error, setError] = useState("");
/** 目录级成功提示(停用 / 启用)。 */
const [notice, setNotice] = useState("");
@@ -319,47 +324,41 @@ export default function FactorsPage() {
setError("请至少选择一个因子");
return;
}
setRunning(true);
setError("");
setJobId("");
setReports({});
setProcessed(0);
try {
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<FactorTestReport>(job_id);
if (out.status === "success" && out.result) {
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);
// 多因子是**一个因子一个 Job**(后端逐因子归档),所以这里顺序提交、逐个 await;
// 每轮都把进度交给统一反馈条:点了第一个就有「正在提交作业…」+ 作业号,不必等全部跑完。
let index = 0;
for (const name of names) {
index += 1;
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 out = await job.run(() => submitJob(spec), {
label: `单因子测试 · ${name}`,
progress: { index, total: names.length },
});
if (out.status === "success" && out.result) {
setReports((prev) => ({ ...prev, [name]: out.result! }));
} else if (out.status === "cancelled") {
// 取消是用户的明确意图,不是错误:已经跑完的报告留在页面上,后面的因子不再跑
break;
} 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 reportNames = Object.keys(reports);
const progressPct = selectedCount === 0 ? 0 : Math.round((processed / selectedCount) * 100);
const tpl = templates.find((x) => x.name === tplName) ?? null;
const tplSpecs = tpl?.param_specs ?? [];
@@ -372,12 +371,12 @@ export default function FactorsPage() {
actions={<Pill tone="accent" icon="flask">{reportNames.length}/{selectedCount} 个已出报告</Pill>}
/>
{error && !running ? (
{error && !active ? (
<Banner tone="error">
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
</Banner>
) : null}
{notice && !running ? <Banner tone="info">{notice}</Banner> : null}
{notice && !active ? <Banner tone="info">{notice}</Banner> : null}
<Card
title="因子目录"
@@ -579,7 +578,7 @@ export default function FactorsPage() {
)}
</Card>
<Card title="运行单因子测试" icon="play" tools={running ? <Pill tone="accent" icon="spinner">执行中</Pill> : undefined}>
<Card title="运行单因子测试" icon="play" tools={active ? <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)} />
@@ -590,34 +589,30 @@ export default function FactorsPage() {
<Btn
variant="primary"
icon="play"
loading={running}
disabled={running || selectedCount === 0}
loading={active}
disabled={active || selectedCount === 0}
onClick={run}
>
{running
? `运行中 ${processed}/${selectedCount}`
{/* 按钮上只保留「跑第几个」这一条信息;秒表 / 阶段 / 作业号都在反馈条上,不重复 */}
{active
? `运行中 ${job.state.progress?.index ?? 0}/${job.state.progress?.total ?? 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}
{/* 反馈条:点了立刻有字(正在提交作业…),随后是真实阶段 + 每秒自增的已用时间 + 可取消 */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="factors-job-progress"
kind="factor_test"
/>
</Card>
{reportNames.length === 0 && !running && !error ? (
{reportNames.length === 0 && !active && !error ? (
<Card>
<Empty
icon="chartLine"
+294
View File
@@ -1078,6 +1078,168 @@ table.tbl {
color: var(--warn);
}
/* 月度收益表:13 列,默认内边距(9px 12px)下要 1070px,装不进 1032px 的详情图层,
右侧会被切掉 38px(就是「右侧内容溢出」的样子)。收到 8px 6px 后 13 列省下约 150px。 */
.tbl--monthly th,
.tbl--monthly td {
padding: 8px 6px;
}
/* ---------- 对比表(指标对比 / 参数差异):首列固定 ----------
两张表都是「一行一个指标/参数 + 每个实验一列」,列数随对比个数增长,窄窗口必然横向滚动。
首列(指标名、参数名)不固定的话,滚到右边看到一串数字却不知道对应哪一项。 */
.tbl--pin-first th:first-child,
.tbl--pin-first td:first-child {
position: sticky;
left: 0;
z-index: 2;
background: var(--surface-1);
}
.tbl--pin-first thead th:first-child {
z-index: 4;
background: #131c2e;
}
.tbl--pin-first tbody tr:hover td:first-child {
background: #17202f;
}
/* ---------- 宽表(实验列表):固定两端 + 中间横向滚动 ----------
实验列表有 11 列(选择 / 对比 / ID / 类型 / 发起时间 / 因子 / 区间 / 摘要 / 版本 / 体积 / 操作)。
1366px 及更窄的窗口一屏放不下:不给右端「操作」列做固定,它会被推到卡片外 ——
用户看到的是「右侧内容溢出了」,勾了「删除」却找不到「详情 / 复跑 / 打开归档」。
所以:左端两列选择框与 ID、右端操作列**固定**,中间列横向滚动;
同时用固定列宽 + 收紧内边距,让 1366px 以上仍然一屏放得下(不出现没必要的滚动条)。
固定列必须有不透明底色,否则中间列会从下面透出来(卡片是渐变,这里取近似纯色)。 */
.table-wrap--wide {
overflow-x: auto;
}
.tbl--wide {
table-layout: fixed;
}
.tbl--wide th,
.tbl--wide td {
padding: 8px 9px;
}
.tbl--wide th:nth-child(1),
.tbl--wide td:nth-child(1) {
width: 42px;
}
.tbl--wide th:nth-child(2),
.tbl--wide td:nth-child(2) {
width: 42px;
}
.tbl--wide th:nth-child(3),
.tbl--wide td:nth-child(3) {
width: 117px;
}
.tbl--wide th:nth-child(4),
.tbl--wide td:nth-child(4) {
width: 62px;
}
.tbl--wide th:nth-child(5),
.tbl--wide td:nth-child(5) {
width: 104px;
}
.tbl--wide th:nth-child(6),
.tbl--wide td:nth-child(6) {
width: 117px;
}
.tbl--wide th:nth-child(7),
.tbl--wide td:nth-child(7) {
width: 104px;
}
.tbl--wide th:nth-child(8),
.tbl--wide td:nth-child(8) {
width: 140px;
}
.tbl--wide th:nth-child(9),
.tbl--wide td:nth-child(9) {
width: 137px;
}
/* 这个表头文字比列宽长(「版本(代码 / 数据)」),允许它折两行;
其它 th 保持 nowrap,否则表头会挤成多行、列宽失控 */
.tbl--wide th:nth-child(9) {
white-space: normal;
line-height: 1.3;
}
.tbl--wide th:nth-child(10),
.tbl--wide td:nth-child(10) {
width: 71px;
}
.tbl--wide th:nth-child(11),
.tbl--wide td:nth-child(11) {
width: 186px;
text-align: right;
}
/* 版本列的数据指纹 chip 很长(d20260904;n≈7688k;a≈7922k;b≈7718k):
不设上限就会横向顶进「操作」列(看起来就是「右侧内容溢出」)。这里在单元格内省略,
完整值仍在 chip 的 title 里(悬停可见),不靠截断字符串假装完整。 */
.tbl--wide td:nth-child(9) .row {
min-width: 0;
flex-wrap: wrap;
}
.tbl--wide td:nth-child(9) .tag,
.tbl--wide td:nth-child(9) .hint {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: bottom;
}
/* ID:等宽字符串不能折行(折成 "EXP-"/"8EA2819B" 就没法扫读),宽度不够时省略号 +
title 给完整值。发起时间与区间**允许折行**:它们按「日期 / 时刻」「起 / 止」自然断行,
比折在日期中间("2020-01-")可读,也让这两列不必占满一行的宽度。 */
.tbl--wide td:nth-child(3) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 左端固定:选择(删除)/ 对比 / ID。left 偏移 = 前面各固定列的宽度之和,
所以上面的列宽改动必须同步改这里的 left(table-layout: fixed 下宽度是确定的)。 */
.tbl--wide th:nth-child(-n + 3),
.tbl--wide td:nth-child(-n + 3) {
position: sticky;
z-index: 2;
background: var(--surface-1);
}
.tbl--wide th:nth-child(1),
.tbl--wide td:nth-child(1) {
left: 0;
}
.tbl--wide th:nth-child(2),
.tbl--wide td:nth-child(2) {
left: 38px;
}
.tbl--wide th:nth-child(3),
.tbl--wide td:nth-child(3) {
left: 76px;
}
/* 右端固定:操作列。加一点内阴影,滚动时能看出「这一列是浮在上面的」 */
.tbl--wide th:nth-child(11),
.tbl--wide td:nth-child(11) {
position: sticky;
right: 0;
z-index: 2;
background: var(--surface-1);
box-shadow: -10px 0 12px -12px rgba(0, 0, 0, 0.9);
}
.tbl--wide thead th {
z-index: 4;
background: #131c2e;
}
/* 行 hover / 选中:固定列的底色也要跟着变,否则整行高亮时两端是「断开」的 */
.tbl--wide tbody tr:hover td:nth-child(-n + 3),
.tbl--wide tbody tr:hover td:nth-child(11) {
background: #17202f;
}
.tbl--wide tbody tr.row-active td:nth-child(-n + 3),
.tbl--wide tbody tr.row-active td:nth-child(11) {
background: #17253d;
}
/* 扩展行 / 详情内嵌 */
.expand-cell {
background: rgba(10, 15, 28, 0.6);
@@ -2185,3 +2347,135 @@ button.chip:hover {
min-width: 0;
flex: 1 1 320px;
}
/* ---------- 作业反馈条(JobProgress):点「运行」后必须立刻看得见 ---------- */
.job-progress {
margin: 12px 0;
padding: 12px 14px;
border: 1px solid var(--line);
border-left: 3px solid var(--accent);
border-radius: var(--r-md);
background: var(--surface-2);
}
.job-progress--run {
border-left-color: var(--accent);
background: var(--accent-soft);
}
.job-progress--ok {
border-left-color: var(--pos);
background: rgba(61, 220, 151, 0.08);
}
.job-progress--bad {
border-left-color: var(--neg);
background: rgba(255, 106, 118, 0.08);
}
.job-progress__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
}
.job-progress__title {
font-weight: 600;
font-size: var(--fs-sm);
}
.job-progress__meta {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-xs);
color: var(--text-2);
font-variant-numeric: tabular-nums;
}
.job-progress__actions {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin-left: auto;
}
.job-progress__err {
margin-top: 8px;
padding: 8px 10px;
border-radius: var(--r-sm);
background: var(--surface-1);
color: var(--neg);
font-size: var(--fs-xs);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* ---------- 买卖说明:分段筛选 + 理由单元格 ---------- */
.seg {
display: inline-flex;
border: 1px solid var(--line);
border-radius: var(--r-sm);
overflow: hidden;
}
.seg__btn {
appearance: none;
border: 0;
background: var(--surface-2);
color: var(--text-2);
font: inherit;
font-size: var(--fs-xs);
padding: 5px 10px;
cursor: pointer;
}
.seg__btn + .seg__btn {
border-left: 1px solid var(--line);
}
.seg__btn.is-on {
background: var(--accent-soft);
color: var(--accent-strong);
font-weight: 600;
}
/* 可排序表头:本体必须和同一行里的控件(如「全选本页」复选框,命中区 34px)等高,
否则 19.2px 高的点击目标既难点中,也会被 UI 规范门禁判为「同排控件不等高」。
行高不因此变化:表头行本来就由 34px 的复选框决定。 */
.th-sort {
appearance: none;
border: 0;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0 6px;
min-height: var(--ctl-h-md);
border-radius: var(--r-sm);
}
.th-sort:hover {
background: rgba(150, 165, 195, 0.075);
}
.reason-cell {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 260px;
}
.reason-cell__head {
display: flex;
align-items: flex-start;
gap: 6px;
flex-wrap: wrap;
}
.reason-cell__text {
font-size: var(--fs-xs);
color: var(--text-1);
overflow-wrap: anywhere;
}
.reason-cell__facts {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.reason-brief {
font-size: var(--fs-xs);
color: var(--text-2);
max-width: 160px;
overflow-wrap: anywhere;
}
.nowrap {
white-space: nowrap;
}
+41 -20
View File
@@ -8,7 +8,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { SymbolLink } from "@/lib/symbols";
import { waitJob } from "@/lib/jobs";
import { useJobRunner } from "@/lib/jobs";
import { factorOptionLabel, pickableFactors } from "@/lib/factors";
import type {
FactorMeta,
@@ -28,6 +28,7 @@ import {
Empty,
SkeletonLines,
} from "@/components/ui";
import { JobProgress } from "@/components/JobProgress";
const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref" }[] = [
{ value: "static.industry", label: "行业 industry", kind: "str" },
@@ -80,8 +81,22 @@ export default function SelectionPage() {
* 拿它拼 /backtest?from_selection=<JOB id> 会让接收端 GET /api/selections/{id} 404 → 坏链接。
*/
const [savedSelectionId, setSavedSelectionId] = useState("");
/**
* 同步选股(POST /selections)正在执行 —— 这条路径是**同步接口**,没有 job_id,
* 所以不能套 useJobRunner(它要轮询 /jobs/{id})。保留 running 只用于这条路径的按钮态。
*/
const [running, setRunning] = useState(false);
const [asyncBusy, setAsyncBusy] = useState(false);
/**
* 异步选股(POST /selections/jobs)的作业反馈统一交给 `useJobRunner`:
* 提交瞬间就有「正在提交作业…」,之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消。
* 此前只有一个 asyncBusy 布尔值,用户点了「异步(全市场)」只看到按钮变灰,
* 不知道到底提交成功没、作业号是多少(正是「点了没反应」的投诉点)。
*/
const job = useJobRunner<SelectionResult>("异步选股(全市场)");
/** 异步作业是否在跑:既用于禁用两个按钮,也用于切换异步按钮的文案。 */
const asyncActive =
job.state.phase === "submitting" || job.state.phase === "queued" || job.state.phase === "running";
/** 只留「同步选股失败 / 历史读取失败」这类**作业之外**的错误;异步作业的失败由反馈条显示后端原文 */
const [error, setError] = useState("");
const [history, setHistory] = useState<SelectionMeta[] | null>(null);
@@ -146,26 +161,24 @@ export default function SelectionPage() {
/** 异步(全市场等长任务):提交 Job 后台执行并轮询(解决同步 60s+)。 */
async function runAsync() {
setAsyncBusy(true);
setError("");
setResult(null);
// 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接)
setSavedSelectionId("");
try {
const { job_id } = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery());
const out = await waitJob<SelectionResult>(job_id);
if (out.status === "success" && out.result) {
setSelectionId(job_id);
setResult(out.result);
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
} catch (e) {
setError((e as Error).message);
} finally {
setAsyncBusy(false);
// 提交闭包把真实 job_id 记下来:页头那个 Pill 沿用老行为显示本次运行的 id,
// 而 job.state.jobId 在 await 之后读到的仍是本轮渲染的旧值(React 状态不会在同一 tick 内更新)
let submittedId = "";
const out = await job.run(async () => {
const r = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery());
submittedId = r.job_id;
return r;
});
if (out.status === "success" && out.result) {
setSelectionId(submittedId);
setResult(out.result);
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
}
// 失败 / 超时 / 取消:反馈条已给出后端原文或「已取消(不产生归档)」,不在这里重复报错
}
async function openHistory(id: string) {
@@ -378,13 +391,21 @@ export default function SelectionPage() {
)}
<div style={{ marginTop: 14 }}>
<Btn variant="primary" icon="play" loading={running} disabled={running || asyncBusy} onClick={run}>
<Btn variant="primary" icon="play" loading={running} disabled={running || asyncActive} onClick={run}>
{running ? "筛选中…" : "执行选股"}
</Btn>
<Btn variant="ghost" icon="clock" loading={asyncBusy} disabled={running || asyncBusy} onClick={runAsync}>
{asyncBusy ? "后台执行中…" : "异步(全市场)"}
<Btn variant="ghost" icon="clock" loading={asyncActive} disabled={running || asyncActive} onClick={runAsync}>
{asyncActive ? "后台执行中…" : "异步(全市场)"}
</Btn>
</div>
{/* 异步作业的反馈条:点了立刻有字 + 真实阶段 + 可取消(同步按钮不走这里) */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="selection-job-progress"
kind="selection"
/>
{error ? (
<div style={{ marginTop: 12 }}>
<Banner tone="error">{error}</Banner>
+15
View File
@@ -138,6 +138,21 @@ export default function SignalsPage() {
{running ? "生成中…" : "生成信号"}
</Btn>
</div>
{/*
为什么这里没有 JobProgress:`POST /signals` 是**同步**接口(backend/app/api/signals.py
只有 POST "",没有 /signals/jobs),拿不到 job_id,也就无从轮询阶段 —— 套 useJobRunner
会去 GET /jobs/{signal_id} 撞 404,而合成一个假的 JobRunState 更是「看似有数据的假象」。
所以这里如实说清三件事:同步连接、没有作业号/阶段/取消、出错时显示后端原文。
*/}
{running ? (
<div className="hint" style={{ marginTop: 12 }} role="status" aria-live="polite">
同步请求已发出:请求期间浏览器一直连着后端(这条路径不是后台作业,不能关页离开)。
因为是同步接口,<b>没有作业号与执行阶段,也没有取消按钮</b>。
该请求通常在数秒内返回;若后端报错或连接中断,下方会显示后端原文。
</div>
) : null}
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card>
+131 -68
View File
@@ -29,6 +29,20 @@ import Link from "next/link";
import { SymbolLink, useSymbolNames } from "@/lib/symbols";
import { adjustLabel } from "@/lib/labels";
import type { ActionRecord, BacktestResult, SymbolCurve } from "@/lib/types";
import { reasonLabel } from "@/lib/types";
import { ChartPopoutLink } from "@/components/ChartPopoutLink";
import { TradeReasonsCard, type FactorLabels } from "@/components/TradeReasons";
import {
drawdownSeries,
equitySeries,
equityValueFormat,
factorCurveNote,
factorSeries,
factorValueFormat,
portfolioMarkers,
symbolMarkers,
symbolSeries,
} from "@/lib/chartSeries";
export interface ArchiveInfo {
id: string;
@@ -70,37 +84,19 @@ export function BacktestResultView({
const nameOf = (c: SymbolCurve) => c.name ?? nameCache[c.symbol] ?? "";
const topRef = useRef<HTMLDivElement | null>(null);
// 组合净值上的买卖点:同一日的成交合并成一个标记,落在当日净值上
const equitySeries = useMemo<LwSeries[]>(
() => [
{
key: "equity",
label: "组合净值(元)",
type: "area",
color: CHART.pos,
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
],
[result.equity_curve]
// 曲线序列统一走 lib/chartSeries(与「新页面放大」共用同一套口径与格式)
const equityData = useMemo<LwSeries[]>(() => equitySeries(result), [result]);
const equityMarkers = useMemo<LwMarker[]>(
() => portfolioMarkers(result.fills, new Set(result.equity_curve.map((p) => p.date))),
[result]
);
const equityMarkers = useMemo<LwMarker[]>(() => {
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>();
for (const f of result.fills ?? []) {
const cur = byDate.get(f.date) ?? { BUY: false, SELL: false };
cur[f.signal] = true;
byDate.set(f.date, cur);
}
const equity = new Set(result.equity_curve.map((p) => p.date));
const out: LwMarker[] = [];
for (const [d, kinds] of byDate) {
if (!equity.has(d)) continue;
if (kinds.BUY) out.push({ time: d, kind: "BUY", text: "买" });
if (kinds.SELL) out.push({ time: d, kind: "SELL", text: "卖" });
}
// 因子键 → 展示名:买卖理由里的因子值要用中文名,不能只甩引擎键
const factorLabels = useMemo<FactorLabels>(() => {
const out: FactorLabels = {};
for (const f of result.factor_curves ?? []) out[f.name] = f.label;
return out;
}, [result.fills, result.equity_curve]);
}, [result.factor_curves]);
const filteredCurves = useMemo(() => {
const q = curveQuery.trim().toLowerCase();
@@ -119,10 +115,12 @@ export function BacktestResultView({
const SECTIONS = [
{ id: "sec-equity", label: "整体收益" },
{ id: "sec-factors", label: "因子曲线" },
{ id: "sec-symbols", label: "个股曲线" },
{ id: "sec-monthly", label: "月度/年度" },
{ id: "sec-holdings", label: "持仓" },
{ id: "sec-trades", label: "成交明细" },
{ id: "sec-reasons", label: "买卖说明" },
];
return (
@@ -175,16 +173,19 @@ export function BacktestResultView({
icon="chartLine"
title="整体收益趋势(含买卖点)"
tools={
<Pill tone="pos">
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
</Pill>
<div className="row" style={{ gap: 6 }}>
<Pill tone="pos">
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
</Pill>
<ChartPopoutLink archiveId={archive?.id} series="equity" />
</div>
}
>
<LwChart
series={equitySeries}
series={equityData}
markers={equityMarkers}
height={320}
valueFormat={(v) => fmtNum(v)}
valueFormat={equityValueFormat}
ariaLabel="组合净值曲线与买卖点"
/>
<div className="hint" style={{ marginTop: 6 }}>
@@ -195,19 +196,15 @@ export function BacktestResultView({
<Card
icon="chartLine"
title="回撤(%)"
tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}
tools={
<div className="row" style={{ gap: 6 }}>
<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>
<ChartPopoutLink archiveId={archive?.id} series="drawdown" />
</div>
}
>
<LwChart
series={[
{
key: "dd",
label: "回撤(%)",
type: "area",
color: CHART.neg,
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
]}
series={drawdownSeries(result)}
height={320}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
@@ -261,6 +258,11 @@ export function BacktestResultView({
曲线口径:该股被持有期间按日复利累计(建仓当日为 0%);未持有期间不绘制,
分段间以直线连接,请以买卖点区分持仓区间。
</span>
<ChartPopoutLink
archiveId={archive?.id}
series={`sym:${activeCurve?.symbol ?? ""}`}
label="放大当前个股"
/>
</div>
{activeCurve ? <SymbolCurveChart curve={activeCurve} /> : null}
<div className="table-wrap" style={{ marginTop: 12 }}>
@@ -303,7 +305,14 @@ export function BacktestResultView({
)}
</Card>
<Card id="sec-monthly" icon="calendar" title="月度收益(%)">
<FactorCurvesCard result={result} fills={result.fills} archiveId={archive?.id} />
<Card
id="sec-monthly"
icon="calendar"
title="月度收益(%)"
tools={<ChartPopoutLink archiveId={archive?.id} series="monthly" />}
>
<LwChart
series={[
{
@@ -408,6 +417,8 @@ export function BacktestResultView({
<th>买价</th>
<th>卖价</th>
<th>收益</th>
<th>为什么买</th>
<th>为什么卖</th>
</tr>
</thead>
<tbody>
@@ -423,6 +434,12 @@ export function BacktestResultView({
<td className={t.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{t.return_pct.toFixed(2)}%
</td>
<td className="reason-brief" title={t.entry_reason?.text ?? undefined}>
<span>{reasonLabel(t.entry_reason?.code)}</span>
</td>
<td className="reason-brief" title={t.exit_reason?.text ?? undefined}>
<span>{reasonLabel(t.exit_reason?.code)}</span>
</td>
</tr>
))}
</tbody>
@@ -433,6 +450,8 @@ export function BacktestResultView({
<NotFilledCard signals={result.signal_history ?? []} />
<TradeReasonsCard signals={result.signal_history ?? []} factorLabels={factorLabels} />
<UnimplementedNote items={result.unimplemented} />
</>
);
@@ -465,31 +484,10 @@ function NotFilledCard({ signals }: { signals: ActionRecord[] }) {
}
function SymbolCurveChart({ curve }: { curve: SymbolCurve }) {
const valueByDate = useMemo(() => new Map(curve.points.map((p) => [p.date, p.value])), [curve]);
const markers = useMemo<LwMarker[]>(
() =>
(curve.marks ?? [])
.filter((a) => valueByDate.has(a.date))
.map((a) => ({
time: a.date,
kind: a.signal,
text: a.signal === "BUY" ? "买" : "卖",
})),
[curve, valueByDate]
);
return (
<LwChart
series={[
{
key: "sym",
label: `${curve.symbol} 持仓期累计收益(%)`,
type: "area",
color: CHART.accent,
data: curve.points.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
]}
markers={markers}
series={symbolSeries(curve)}
markers={symbolMarkers(curve)}
height={300}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
@@ -498,6 +496,71 @@ function SymbolCurveChart({ curve }: { curve: SymbolCurve }) {
);
}
/**
* 因子曲线:策略里每个因子一张图(**原始值**,不做 z-score)。
*
* 用户要求:「本因子的买卖依据是股息率,那么要增加股息率曲线」。这里把**同一批成交日**
* 标在因子曲线上,于是能一眼看出「买在什么水平、卖在什么水平」,而不是只看净值曲线
* 猜原因。口径(持仓加权平均、不按方向取反、空仓不落点)写在每张图下方。
*/
function FactorCurvesCard({
result,
fills,
archiveId,
}: {
result: BacktestResult;
fills?: ActionRecord[];
archiveId?: string | null;
}) {
const curves = result.factor_curves ?? [];
if (!curves.length) return null;
return (
<Card
id="sec-factors"
icon="layers"
title={`因子曲线 · ${curves.length} 个(买卖依据的水平)`}
tools={<Pill tone="violet">持仓加权平均原始值</Pill>}
>
<div className="hint" style={{ marginBottom: 10 }}>
每个因子一条曲线:值为当日「持仓股票按市值加权平均」的因子原始值,用来回答
「买入时这个因子处于什么水平、卖出时又变到哪」。图上 ▲/▼ 是「组合的成交日」(同一套买卖点),
因此能直接对照「因子在什么水平触发买卖」。曲线未做 z-score、也未按方向取反;
低为好的因子(方向标注为「越低越好」)曲线升高不等于更好。
</div>
<div className="chart-grid">
{curves.map((c, i) => {
const dates = new Set(c.points.map((p) => p.date));
return (
<Card
key={c.name}
title={c.label}
tools={
<div className="row" style={{ gap: 6 }}>
<Pill tone={c.direction === "lower_is_better" ? "warn" : "pos"}>
{c.direction === "lower_is_better" ? "越低越好" : "越高越好"}
</Pill>
<ChartPopoutLink archiveId={archiveId} series={`factor:${c.name}`} />
</div>
}
>
<LwChart
series={[factorSeries(c, i)]}
markers={portfolioMarkers(fills, dates)}
height={280}
valueFormat={factorValueFormat(c)}
ariaLabel={`因子 ${c.label} 的持仓加权曲线与买卖点`}
/>
<div className="hint" style={{ marginTop: 6 }}>
{factorCurveNote(c)}
</div>
</Card>
);
})}
</div>
</Card>
);
}
function poolLabel(result: BacktestResult): string {
const sel = result.config_snapshot?.selection as
| { top_n?: number; hold_top_x?: number | null }
@@ -0,0 +1,50 @@
"use client";
/**
* 「新页面放大」入口:把某条曲线在 `/charts/{归档id}?s={曲线}` 里整页打开。
*
* 为什么要走归档而不是把数据塞进新标签页:新标签页与原页不共享内存/存储,
* 唯一可靠的传递方式就是 URL;而回测结果本来就**已经归档**(`experiment_id`),
* 让放大页从归档读同一份数据,还能顺带保证「看到的图与归档一致」。
*
* 没有归档 id 时(同步接口没归档、或归档已被删除)**不给假按钮**:直接说明原因。
*/
import Link from "next/link";
import { Icon } from "@/components/icons";
export function ChartPopoutLink({
archiveId,
series,
label = "新页面放大",
size = "sm",
}: {
archiveId?: string | null;
series: string;
label?: string;
size?: "sm" | "md";
}) {
if (!archiveId) {
return (
<span
className="hint"
title="本次结果没有归档 id(未归档或归档已删除),无法在新页面打开同一份数据"
>
未归档,无法放大
</span>
);
}
return (
<Link
className={size === "sm" ? "btn btn--sm" : "btn"}
href={`/charts/${encodeURIComponent(archiveId)}?s=${encodeURIComponent(series)}`}
target="_blank"
rel="noreferrer"
title="在新标签页整页打开这条曲线"
>
<Icon name="arrowUpRight" size={13} />
<span>{label}</span>
</Link>
);
}
+222
View File
@@ -0,0 +1,222 @@
"use client";
/**
* 作业反馈条(全站统一)。
*
* 解决的问题(用户反馈原话):「点了回测没有任何反馈,不清楚是不是已经开始回测」。
* 因此这里有三条硬要求:
* 1. **点了立刻有字**:`submitting` 阶段就显示「正在提交作业…」,不等到后端返回;
* 2. **看得出在动**:作业号 + 真实阶段 + **每秒自增**的已用时间(阶段没变也在走);
* 3. **出事了能自救**:排队中可「取消」,失败显示后端原文,成功给「打开归档 / 去对比」。
*
* 反馈条出现时会自动滚到视野中央 —— 按钮常在长表单底部,反馈条在页面顶部,
* 不滚一下用户就看不见(这正是「没有反馈」错觉的来源之一)。
*/
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Btn, Pill } from "@/components/ui";
import { Icon } from "@/components/icons";
import { STAGE_LABEL, STAGE_PIPELINES, type JobKind, type JobRunState } from "@/lib/jobs";
/** 已用时间:< 60s 显示秒,否则 mm:ss(跑几分钟时更好读) */
function fmtElapsed(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
}
function fmtClock(d: Date | null): string {
if (!d) return "";
return d.toLocaleTimeString("zh-CN", { hour12: false });
}
export function JobProgress<T>({
state,
onCancel,
onDismiss,
/** 完成后是否自动滚到视野(提交时恒滚,完成后不打断阅读) */
anchorId = "job-progress",
/** 作业类型:决定阶段小圆点画哪几条 —— 因子测试没有「逐择股日选股 / 撮合与净值结算」,
* 选股也只有一个真实阶段,全站共用一张表会把这些不存在的阶段画成「○ 待开始」。 */
kind = "backtest",
}: {
state: JobRunState<T>;
onCancel?: () => void;
onDismiss?: () => void;
anchorId?: string;
kind?: JobKind;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const scrolled = useRef(false);
const active = state.phase === "submitting" || state.phase === "queued" || state.phase === "running";
/** 本类作业真实会走的阶段(不含 queued / done —— 那是作业状态,不是后端阶段) */
const pipeline = STAGE_PIPELINES[kind];
/**
* 后端阶段是前端还不认识的枚举时(后端先加、前端文案没跟上),
* 保留**上一个已知阶段**的进度而不是把整排圆点打成灰(那看起来像「什么都没在跑」);
* 原始枚举另在 Pill 上兜底显示(如「执行中(factor_calculation)」),不出现裸英文。
*/
const [lastKnown, setLastKnown] = useState("");
useEffect(() => {
// 新一轮提交(submitting)先清空:否则同一页第二次运行时,会拿上一轮的最后阶段当进度,
// 新作业明明刚排队却显示「已完成到分析的 ✓」——那是假的进度。
if (state.phase === "submitting") {
setLastKnown("");
return;
}
if (pipeline.includes(state.stage)) setLastKnown(state.stage);
}, [state.phase, state.stage, pipeline]);
// 提交瞬间(含同一页面第二次提交)把反馈条滚进视野:按钮在长表单底部时,
// 顶部出现的反馈条不滚过去就等于没显示。
useEffect(() => {
if (state.phase === "idle") {
scrolled.current = false;
return;
}
if ((state.phase === "submitting" || state.phase === "queued") && !scrolled.current) {
scrolled.current = true;
ref.current?.scrollIntoView({ block: "center", behavior: "smooth" });
}
}, [state.phase]);
if (state.phase === "idle") return null;
const stageText = state.stage
? state.stage === "cancelling"
? "正在取消…"
: (STAGE_LABEL[state.stage] ?? `执行中(${state.stage})`)
: "等待后端回报阶段";
const cur = pipeline.indexOf(pipeline.includes(state.stage) ? state.stage : lastKnown);
const archiveHref = state.experimentId
? `/experiments?exp=${encodeURIComponent(state.experimentId)}`
: null;
const tone =
state.phase === "success" ? "job-progress--ok" : state.phase === "failed" || state.phase === "timeout" ? "job-progress--bad" : "job-progress--run";
return (
<div
ref={ref}
id={anchorId}
className={`job-progress ${tone}`}
role="status"
aria-live="polite"
data-phase={state.phase}
>
<div className="job-progress__head">
{active ? (
<Pill tone="accent" icon="spinner">
{state.phase === "submitting" ? "正在提交作业…" : stageText}
</Pill>
) : state.phase === "success" ? (
<Pill tone="pos" icon="check">
已完成
</Pill>
) : state.phase === "cancelled" ? (
<Pill tone="warn" icon="x">
已取消
</Pill>
) : (
<Pill tone="neg" icon="alert">
{state.phase === "timeout" ? "等待超时" : "执行失败"}
</Pill>
)}
<span className="job-progress__title">
{state.label || "研究任务"}
{state.progress ? (
<span className="hint">
{" "}
(第 {state.progress.index}/{state.progress.total} 个)
</span>
) : null}
</span>
<span className="job-progress__meta">
{state.jobId ? (
<>
作业 <span className="mono">{state.jobId}</span>
</>
) : (
<span className="hint">正在换取作业号…</span>
)}
{state.startedAt ? <span className="hint">发起于 {fmtClock(state.startedAt)}</span> : null}
{active ? <span className="mono">已用 {fmtElapsed(state.elapsedMs)}</span> : null}
</span>
<span className="job-progress__actions">
{active && onCancel && state.jobId ? (
<Btn size="sm" icon="x" loading={state.cancelling} disabled={state.cancelling} onClick={onCancel}>
取消任务
</Btn>
) : null}
{!active && archiveHref ? (
<Link className="btn btn--sm" href={archiveHref}>
<span>去对比</span>
</Link>
) : null}
{!active && state.experimentId ? (
<Link
className="btn btn--sm"
href={`/experiments/${encodeURIComponent(state.experimentId)}`}
target="_blank"
rel="noreferrer"
>
<span>打开归档</span>
<Icon name="arrowUpRight" size={13} />
</Link>
) : null}
{!active && onDismiss ? (
<Btn size="sm" onClick={onDismiss}>
收起
</Btn>
) : null}
</span>
</div>
{active ? (
<div className="stages" style={{ marginTop: 8 }}>
{pipeline.map((st) => {
const mine = pipeline.indexOf(st);
const cls = cur >= 0 && mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage";
return (
<span className={cls} key={st}>
{cur >= 0 && mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st}
</span>
);
})}
</div>
) : null}
{active ? (
<div className="hint" style={{ marginTop: 6 }}>
后台执行中:可以离开本页(刷新 / 关页都不影响),跑完会自动归档到「实验对比」。
全市场多年区间通常 3~5 分钟;阶段与已用时间来自后端真实状态,不是假进度条。
</div>
) : null}
{state.phase === "failed" || state.phase === "timeout" ? (
<div className="job-progress__err" role="alert">
{state.error || "任务失败,后端没有给出原因。"}
</div>
) : null}
{state.phase === "cancelled" ? (
<div className="hint" style={{ marginTop: 6 }}>
任务已取消,没有归档(取消的任务不产生实验记录)。可以改完参数再跑一次。
</div>
) : null}
{state.phase === "success" ? (
<div className="hint" style={{ marginTop: 6 }}>
{state.experimentId
? `已归档为实验 ${state.experimentId},可在「实验对比」里与其它版本叠曲线、比参数。`
: "已完成(本次没有归档,可能是同步任务)。"}
</div>
) : null}
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
/*
* 图层样式(只服务 components/Modal.tsx)。
*
* 刻意用 CSS Module 而不是 app/globals.css:全局样式表被多个页面共用,
* 而这里的选择器名(backdrop/dialog/head/body)太通用,写进全局极易与别处冲突;
* CSS Module 会在构建时把类名变成局部哈希,天然隔离。
* 颜色/圆角/间距全部取 globals.css 里已有的设计令牌,保证与全站同一套视觉。
*/
.backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-5);
/* 半透明遮罩 + 轻微毛玻璃:背后的列表仍可辨认位置,但不会与图层内容抢注意力 */
background: rgba(4, 8, 18, 0.66);
backdrop-filter: blur(3px);
overflow: auto;
}
.dialog {
display: flex;
flex-direction: column;
width: min(1100px, 100%);
/* 小屏/长内容时图层本身不超过 90vh,滚动交给 .body */
max-height: 90vh;
border: 1px solid var(--line-strong);
border-radius: var(--r-lg);
background: var(--surface-1);
box-shadow: var(--shadow-pop);
/* 对话框用 tabIndex={-1} 聚焦:去掉浏览器默认的聚焦描边,改用下面的 :focus-visible */
outline: none;
}
.head {
flex: 0 0 auto;
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);
border-radius: var(--r-lg) var(--r-lg) 0 0;
background: var(--surface-2);
}
.title {
margin: 0;
font-size: var(--fs-lg);
font-weight: 600;
color: var(--text-1);
overflow-wrap: anywhere;
}
.tools {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
.close {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
/* 图标按钮用标准控件高度(34px)而不是密集行的 28px:图层是浮层,
关闭是唯一出口,点击目标不该比正文按钮更小;字号也调大一档,
避免「×」在深色底上细到看不见(截图逐字核对时发现对比度偏低)。 */
width: var(--ctl-h-md);
height: var(--ctl-h-md);
border: 1px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--surface-3);
color: var(--text-1);
font-size: var(--fs-md);
line-height: 1;
cursor: pointer;
}
.close:hover {
color: var(--text-1);
border-color: var(--line-strong);
}
.close:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* 内容区:归档结果可以很长(净值曲线 + 持仓 + 成交表),所以这里独立滚动,
标题栏与关闭按钮始终可见(不用把用户滚回顶部才能关掉图层)。 */
.body {
flex: 1 1 auto;
max-height: 90vh;
overflow: auto;
padding: var(--sp-4);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
/**
* 通用图层(overlay / modal)。
*
* 为什么自建而不引第三方弹窗库:全站只有「实验详情预览」这一处需要图层,为一个
* 需求引入 react-modal / focus-trap 之类依赖不划算。但下面四件事直接影响可用性,
* 必须自己做对(不是装饰):
* ① **锁 body 滚动**:不锁的话滚轮会滚到背后的列表,用户会以为图层没盖住;
* ② **ESC 关闭 + 点遮罩关闭**:且点内容区不关(否则在图层里选中文字、拖滚动条
* 都会误关);遮罩判定用 mousedown 而不是 click —— 从内容区按下、拖到遮罩上
* 再松手时 click 的目标是两者的共同祖先(遮罩),用 click 会误判为「点了遮罩」;
* ③ **焦点管理**:打开时焦点进对话框,关闭后**还给触发它的按钮** —— 否则键盘
* 用户关掉图层后焦点回到 <body>,再按 Tab 会从页首重新走一遍;
* ④ **role="dialog" + aria-modal + aria-labelledby**:屏幕阅读器才知道「这是一个
* 对话框、它的标题是什么、背景内容应当被屏蔽」。
*
* 用 createPortal 挂到 document.body:图层必须脱离列表页的层叠上下文与 overflow
* 裁剪 —— 放在带 transform / overflow:hidden 的祖先里会被裁掉或压不住子元素。
*
* 样式走 `components/Modal.module.css`(CSS Module),刻意不写进 app/globals.css:
* 全局样式表被多处共用,改它容易与并行的改动互相覆盖;图层样式只服务本组件。
*/
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Icon } from "@/components/icons";
import styles from "./Modal.module.css";
export function Modal({
title,
onClose,
tools,
children,
returnFocusTo,
}: {
/** 标题栏文案:同时作为 aria-labelledby 指向的可见标题 */
title: ReactNode;
onClose: () => void;
/** 标题栏右侧动作(如「在新页面打开完整归档」);关闭按钮由本组件自己追加 */
tools?: ReactNode;
children: ReactNode;
/**
* 关闭后要把焦点还给谁。
* 调用方显式传入触发按钮最可靠(例如每行的「详情」按钮);不传时退化为
* 「打开那一刻 document.activeElement 是谁」,对点击入口通常也成立。
*/
returnFocusTo?: HTMLElement | null;
}) {
const titleId = useId();
const dialogRef = useRef<HTMLDivElement | null>(null);
/**
* createPortal 需要 document,而 Next 会对客户端组件做一次 SSR:
* 直接 portal 会在服务端拿不到 document 而抛错。首帧不渲染,挂载后再上图层。
*/
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
/**
* onClose 每次父组件渲染都是新函数;用 ref 保存,下面的副作用就能只在挂载时
* 绑定一次键盘监听 —— 反复解绑/重绑期间正好按下的 ESC 会被漏掉。
*/
const closeRef = useRef(onClose);
useEffect(() => {
closeRef.current = onClose;
});
useEffect(() => {
if (!mounted) return;
const restore = returnFocusTo ?? (document.activeElement as HTMLElement | null);
// 锁滚动:保存原值而不是关闭时写 "",否则会覆盖页面本来就设置过的 overflow
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
// 打开时把焦点移进对话框:tabIndex={-1} 让它可聚焦,但不会进 Tab 序列
dialogRef.current?.focus();
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
// 图层是 aria-modal:ESC 只应关掉图层,不应冒泡去触发页面的其它快捷键
e.preventDefault();
e.stopPropagation();
closeRef.current();
return;
}
if (e.key !== "Tab") return;
// 焦点陷阱:不拦住的话 Tab 会跑到图层背后的列表里(视觉上"消失"了)
const root = dialogRef.current;
if (!root) return;
const items = Array.from(
root.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
if (items.length === 0) {
e.preventDefault();
root.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || active === root)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", onKeyDown, true);
return () => {
document.removeEventListener("keydown", onKeyDown, true);
document.body.style.overflow = prevOverflow;
// 触发按钮可能已随列表刷新消失(例如刚删掉这一行):只有还在文档里才回焦,
// 否则 focus() 无效,焦点会掉回 <body>(键盘用户会"掉回页首")。
if (restore && restore.isConnected) restore.focus();
};
// 只在挂载/卸载时执行;returnFocusTo 只在打开那一刻读一次,不参与后续渲染
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mounted]);
if (!mounted) return null;
return createPortal(
<div
className={styles.backdrop}
// 点遮罩关闭:判定 target === currentTarget,点内容区(子元素)不会命中
onMouseDown={(e) => {
if (e.target === e.currentTarget) closeRef.current();
}}
>
<div
ref={dialogRef}
className={styles.dialog}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
>
<div className={styles.head}>
<h2 id={titleId} className={styles.title}>
{title}
</h2>
<div className={styles.tools}>
{tools}
<button
type="button"
className={styles.close}
onClick={() => closeRef.current()}
aria-label="关闭详情图层"
title="关闭(ESC)"
>
<Icon name="x" size={16} />
</button>
</div>
</div>
<div className={styles.body}>{children}</div>
</div>
</div>,
document.body
);
}
+311
View File
@@ -0,0 +1,311 @@
"use client";
/**
* 买卖理由表(回测结果的「所有买卖点为什么买 / 为什么卖」)。
*
* 用户要求:「在所有买卖点详细说明买卖理由。用数据说话。」因此这里的原则是:
* 1. **一个点都不省**:成交的、没成交的(涨停/停牌/现金不足)、Tmin 保护暂留的,
* 全部来自 `signal_history`,不漏;
* 2. **数字只来自引擎**:`reason.data` 里的名次 / 综合分 / 因子原始值 / 持有交易日
* 直接展示,前端不做任何推算(免得出现「看起来像真的」的数字);
* 3. **可核对**:原因分类(code)给中文短标签,点击筛选;理由原文可读。
*
* 老归档(2026-10 之前)没有 `reason` 字段,退化为展示原有的 `reject_reason`,
* 并明确标注「旧归档无结构化理由」——不假装有数据。
*/
import { useMemo, useState } from "react";
import type { ActionRecord, TradeReason } from "@/lib/types";
import { reasonLabel } from "@/lib/types";
import { Card, Pill } from "@/components/ui";
import { SymbolLink } from "@/lib/symbols";
/** 因子键 → 展示名(来自结果里的 factor_curves;取不到就用引擎键) */
export type FactorLabels = Record<string, string>;
function fmtNum(v: number, digits = 2): string {
return v.toLocaleString("zh-CN", { maximumFractionDigits: digits });
}
/**
* 定点格式化,**与后端 `f"{v:.4f}"` 的舍入规则一致**(四舍六入五成双)。
*
* 为什么不能用 `toFixed`:引擎理由原文里 0.03125 写成 `0.0312`(Python 是 bankers'
* rounding),而 JS `toFixed` 是「五入」→ `0.0313`。同一个综合分在「理由原文」和旁边的
* 数字标签里显示成两个数,用户会合理地怀疑数据不一致 —— 这类不一致必须消掉。
*
* 实现:先展开成**足够长的十进制**(40 位小数,覆盖 double 的有效位,避免「先按
* digits+2 位舍入」制造出假的中点),再对这个十进制字符串做「五成双」舍入。
*/
function fmtFixed(v: number, digits: number): string {
if (!Number.isFinite(v)) return String(v);
const neg = v < 0;
const s = Math.abs(v).toFixed(40);
const [intPart, fracPart = ""] = s.split(".");
const keep = fracPart.slice(0, digits).padEnd(digits, "0");
const rest = fracPart.slice(digits);
const firstDropped = rest.length ? rest.charCodeAt(0) - 48 : 0;
const laterNonZero = /[1-9]/.test(rest.slice(1));
const digitsArr = (intPart + keep).split("");
const lastDigit = Number(digitsArr[digitsArr.length - 1]);
if (firstDropped > 5 || (firstDropped === 5 && (laterNonZero || lastDigit % 2 === 1))) {
let i = digitsArr.length - 1;
for (; i >= 0; i -= 1) {
const d = Number(digitsArr[i]) + 1;
if (d < 10) {
digitsArr[i] = String(d);
break;
}
digitsArr[i] = "0";
}
if (i < 0) digitsArr.unshift("1");
}
const all = digitsArr.join("");
const intOut = all.slice(0, all.length - digits) || "0";
const fracOut = digits ? all.slice(all.length - digits) : "";
return `${neg ? "-" : ""}${intOut}${digits ? `.${fracOut}` : ""}`;
}
/** 结构化理由里「用数据说话」的那几个数字(有才显示,没有不编) */
function reasonFacts(reason: TradeReason, factorLabels: FactorLabels): string[] {
const d = reason.data ?? {};
const out: string[] = [];
if (typeof d.rank === "number") {
out.push(
`综合分第 ${d.rank}${typeof d.total === "number" ? `/${d.total}` : ""} 名` +
(typeof d.top_n === "number" ? `(TopN=${d.top_n})` : "")
);
} else if (typeof d.total === "number") {
out.push(`候选 ${d.total} 只(当日无该股分数)`);
}
if (typeof d.score === "number") out.push(`综合分 ${fmtFixed(d.score, 4)}`);
if (typeof d.hold_days === "number") {
out.push(
`持有 ${d.hold_days} 个交易日` +
(typeof d.tmin === "number" ? `(Tmin=${d.tmin})` : "") +
(typeof d.tmax === "number" ? `(Tmax=${d.tmax})` : "")
);
}
if (typeof d.close_prev_ratio === "number") {
out.push(
`收盘/前收 = ${fmtFixed(d.close_prev_ratio, 3)}` +
(typeof d.limit_ratio === "number" ? `(阈值 ${fmtFixed(d.limit_ratio, 3)})` : "")
);
}
if (typeof d.budget === "number") out.push(`可用预算 ${fmtNum(d.budget)} 元`);
if (typeof d.min_commission === "number") out.push(`最低佣金 ${fmtNum(d.min_commission)} 元`);
if (d.in_pool === false) out.push("已不在候选池(被股票池/条件过滤)");
if (typeof d.return_pct === "number") out.push(`本笔收益 ${fmtFixed(d.return_pct, 2)}%`);
const factors = d.factors ?? {};
for (const [key, value] of Object.entries(factors)) {
if (typeof value !== "number") continue;
out.push(`${factorLabels[key] ?? key} = ${fmtFixed(value, 4)}`);
}
return out;
}
/** 单条理由:分类标签 + 理由原文 + 关键数字 */
export function TradeReasonCell({
reason,
fallback,
factorLabels = {},
}: {
reason?: TradeReason | null;
fallback?: string | null;
factorLabels?: FactorLabels;
}) {
if (!reason) {
// 老归档没有结构化理由:如实标注,并退回执行层文案(不假装有数据)
return (
<span className="hint">
{fallback ? `${fallback}(旧归档无结构化理由)` : "—(旧归档无结构化理由)"}
</span>
);
}
const facts = reasonFacts(reason, factorLabels);
return (
<div className="reason-cell">
<div className="reason-cell__head">
<Pill tone={reason.code.startsWith("buy") ? "pos" : "warn"}>{reasonLabel(reason.code)}</Pill>
<span className="reason-cell__text">{reason.text}</span>
</div>
{facts.length ? (
<div className="reason-cell__facts">
{facts.map((f) => (
<span className="chip" key={f}>
{f}
</span>
))}
</div>
) : null}
</div>
);
}
type Side = "all" | "BUY" | "SELL";
type Fill = "all" | "filled" | "unfilled";
/**
* 全部买卖点 + 理由(默认按日期倒序,最新的一笔在最上面)。
*
* 为什么默认倒序:回测结果里「最近发生了什么」通常是最想看的;要按时间顺序读,
* 点表头「日期」即可切换。
*/
export function TradeReasonsCard({
signals,
factorLabels = {},
}: {
signals: ActionRecord[];
factorLabels?: FactorLabels;
}) {
const [side, setSide] = useState<Side>("all");
const [fill, setFill] = useState<Fill>("all");
const [q, setQ] = useState("");
const [desc, setDesc] = useState(true);
const rows = useMemo(() => {
const needle = q.trim().toLowerCase();
const out = signals.filter((a) => {
if (side !== "all" && a.signal !== side) return false;
if (fill === "filled" && !a.filled) return false;
if (fill === "unfilled" && a.filled) return false;
if (!needle) return true;
return (
a.symbol.toLowerCase().includes(needle) ||
(a.name ?? "").toLowerCase().includes(needle) ||
(a.reason?.text ?? "").toLowerCase().includes(needle)
);
});
out.sort((a, b) => (a.date === b.date ? a.symbol.localeCompare(b.symbol) : a.date < b.date ? -1 : 1));
return desc ? out.reverse() : out;
}, [signals, side, fill, q, desc]);
const filled = signals.filter((a) => a.filled).length;
const withReason = signals.filter((a) => a.reason).length;
if (!signals.length) return null;
return (
<Card
id="sec-reasons"
icon="book"
title={`买卖说明 · ${signals.length} 个买卖点`}
tools={
<div className="row" style={{ gap: 6, flexWrap: "wrap" }}>
<Pill>已成交 {filled}</Pill>
<Pill tone="warn">未成交 {signals.length - filled}</Pill>
<Pill tone={withReason === signals.length ? "pos" : "warn"}>
{withReason}/{signals.length} 条带结构化理由
</Pill>
</div>
}
>
<div className="row" style={{ gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
<div className="seg">
{(
[
["all", "全部"],
["BUY", "买入"],
["SELL", "卖出"],
] as [Side, string][]
).map(([v, label]) => (
<button
key={v}
type="button"
className={side === v ? "seg__btn is-on" : "seg__btn"}
onClick={() => setSide(v)}
>
{label}
</button>
))}
</div>
<div className="seg">
{(
[
["all", "不限成交"],
["filled", "只看成交"],
["unfilled", "只看未成交"],
] as [Fill, string][]
).map(([v, label]) => (
<button
key={v}
type="button"
className={fill === v ? "seg__btn is-on" : "seg__btn"}
onClick={() => setFill(v)}
>
{label}
</button>
))}
</div>
<input
className="input input--search"
placeholder="搜索代码 / 名称 / 理由"
value={q}
onChange={(e) => setQ(e.target.value)}
aria-label="搜索买卖点"
/>
<Pill>{rows.length} 条</Pill>
</div>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>
<button type="button" className="th-sort" onClick={() => setDesc((d) => !d)}>
日期 {desc ? "↓" : "↑"}
</button>
</th>
<th>方向</th>
<th>股票</th>
<th>价格</th>
<th>买卖理由(引擎给出的当时数字)</th>
</tr>
</thead>
<tbody>
{rows.map((a) => (
<tr key={`${a.signal}-${a.date}-${a.symbol}-${a.price ?? ""}`}>
<td className="mono dim nowrap">{a.date}</td>
<td>
{a.filled ? (
<Pill tone={a.signal === "BUY" ? "pos" : "neg"}>
{a.signal === "BUY" ? "买入" : "卖出"}
</Pill>
) : (
<Pill tone="warn" icon="alert">
{a.signal === "BUY" ? "想买未成" : "想卖未成"}
</Pill>
)}
</td>
<td className="sym-cell">
<SymbolLink symbol={a.symbol} name={a.name} />
</td>
<td className="mono nowrap">{a.price != null ? fmtFixed(a.price, 2) : "—"}</td>
<td>
<TradeReasonCell
reason={a.reason}
fallback={a.reject_reason}
factorLabels={factorLabels}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="hint" style={{ marginTop: 8 }}>
「想买未成 / 想卖未成」= 策略当天确实要下单,但被涨停、跌停、停牌或现金挡住
(执行层原因在理由里写清)。名次、综合分、因子值、持有交易日都取自「引擎当时的计算」,
界面不做二次推算;因子值是原始值(未做 z-score、不按方向取反)。
{withReason < signals.length
? ` 有 ${signals.length - withReason} 条来自 2026-10 之前的旧归档,当时还没有结构化理由。`
: ""}
{" "}覆盖边界:「当日排名在 TopN 之外、策略本来就无意买入」的候选不算买卖点
(要看完整候选与名次,请看选股明细);每个真正的买卖点 —— 包括涨停、停牌、
跌停、现金不足这类没成交的 —— 都在上表里。
</div>
</Card>
);
}
+35 -3
View File
@@ -62,8 +62,17 @@ export interface LwChartProps {
series: LwSeries[];
markers?: LwMarker[];
height?: number;
/** 悬浮提示与价格轴的数值格式 */
/** 悬浮提示与价格轴的数值格式(客户端组件用;Server Component 请用 formatKey) */
valueFormat?: (v: number) => string;
/**
* 数值格式的**可序列化标识**。
*
* 为什么需要它:Server Component 不能把函数传给 Client Component(`valueFormat`
* 会直接 500:「Functions cannot be passed directly to Client Components」)。
* 因此服务端渲染的图表(归档详情、曲线放大页)用这个标识指定格式,由本组件在
* 客户端解析成函数 —— 两端看到的数字格式仍然只有一份定义。
*/
formatKey?: LwFormatKey;
/** 画一条 0 基准虚线(收益率曲线推荐开启) */
zeroLine?: boolean;
legend?: boolean;
@@ -73,6 +82,27 @@ export interface LwChartProps {
type AnySeries = ISeriesApi<"Line"> | ISeriesApi<"Area"> | ISeriesApi<"Histogram">;
/** 数值格式的可序列化标识(Server Component ↔ Client Component 的桥) */
export type LwFormatKey =
| "num"
| "num0"
| "pct2"
| "pct3"
| "times3"
| "auto4"
/** 原值为小数、按百分数显示(0.15 → 15.00%) */
| "frac-pct";
export const FORMATTERS: Record<LwFormatKey, (v: number) => string> = {
num: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 }),
num0: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 0 }),
pct2: (v) => `${v.toFixed(2)}%`,
pct3: (v) => `${v.toFixed(3)}%`,
times3: (v) => `${v.toFixed(3)}×`,
auto4: (v) => v.toFixed(4),
"frac-pct": (v) => `${(v * 100).toFixed(2)}%`,
};
function toTime(t: string): Time {
return t as Time;
}
@@ -102,6 +132,7 @@ export function LwChart({
markers = [],
height = 320,
valueFormat,
formatKey,
zeroLine = false,
legend = true,
ariaLabel,
@@ -114,8 +145,9 @@ export function LwChart({
const zeroLineDrawn = useRef(false);
/** 图例隐藏集合:tooltip 订阅里读取,用 ref 避免闭包过期 */
const hiddenRef = useRef<Set<string>>(new Set());
const fmtRef = useRef(valueFormat);
fmtRef.current = valueFormat;
const resolvedFormat = valueFormat ?? (formatKey ? FORMATTERS[formatKey] : undefined);
const fmtRef = useRef(resolvedFormat);
fmtRef.current = resolvedFormat;
const [hidden, setHidden] = useState<Set<string>>(new Set());
const [tip, setTip] = useState<{
+3 -1
View File
@@ -395,7 +395,9 @@ export function MonthlyReturnsTable({
if (years.length === 0) return <Empty title="无月度收益数据" />;
return (
<div className="table-wrap">
<table className="tbl">
{/* 13 列(年份 + 12 个月):窄容器(实验详情图层 1032px、手机)一定放不下,
所以收紧内边距 + 首列「年份」固定,让月份横向滚动时也知道看的是哪一年。 */}
<table className="tbl tbl--monthly tbl--pin-first">
<thead>
<tr>
<th>年份</th>
+152
View File
@@ -0,0 +1,152 @@
/**
* 回测结果曲线 → 图表序列的**唯一构造处**。
*
* 为什么单独抽出来:同一条曲线会在三个地方出现 —— 回测结果页、归档详情页、
* 以及「新页面放大」的 `/charts/{id}`。三处各写一份格式化逻辑,迟早出现
* 「同一张图两个页面数值口径不一样」。这里把每类曲线的取数、颜色、数值格式、
* 买卖点标注统一成函数,页面只管摆放。
*/
import type { LwFormatKey, LwSeries, LwMarker } from "@/components/charts/LwChart";
import { CHART, fmtNum } from "@/components/charts/theme";
import type { ActionRecord, BacktestResult, FactorCurve, SymbolCurve } from "@/lib/types";
/** 因子值的量纲说明与人读格式("%" / "倍数" / "小数",None = 无量纲) */
export function factorValueFormat(curve: FactorCurve): (v: number) => string {
if (curve.unit === "%") return (v) => `${v.toFixed(3)}%`;
if (curve.unit === "倍数") return (v) => `${v.toFixed(3)}×`;
// 小数:原值 0.15 = 15% —— 图上按百分数显示更好读,但标签里会注明「原值为小数」
if (curve.unit === "小数") return (v) => `${(v * 100).toFixed(2)}%`;
return (v) => v.toFixed(4);
}
/**
* 因子曲线的格式标识(给 Server Component 用)。
*
* 与 `factorValueFormat` 必须一致:两处定义同一件事会漂移,因此这里直接按 unit 分支,
* 且在单测/自检里比对两者的输出。
*/
export function factorFormatKey(curve: FactorCurve): LwFormatKey {
if (curve.unit === "%") return "pct3";
if (curve.unit === "倍数") return "times3";
if (curve.unit === "小数") return "frac-pct";
return "auto4";
}
/** 因子曲线口径的完整说明(图上必须写,避免把「持仓加权平均」读成别的口径) */
export function factorCurveNote(curve: FactorCurve): string {
const unitNote =
curve.unit === "小数"
? "原值为小数(0.15 即 15%),图上按百分数显示"
: curve.unit
? `单位:${curve.unit}`
: "无量纲";
const dir = curve.direction === "lower_is_better" ? "越低越好" : "越高越好";
return (
`口径:每个交易日「当日持仓按市值加权平均」的因子原始值(不做 z-score、不按方向取反),` +
`空仓日不落点。${unitNote};方向 ${dir}。`
);
}
/** 因子曲线序列 */
export function factorSeries(curve: FactorCurve, index = 0): LwSeries {
const colors = [CHART.accent, CHART.violet, CHART.pos, CHART.neg, CHART.amber];
return {
key: `factor-${curve.name}`,
label: `${curve.label}(持仓加权)`,
type: "line",
lineWidth: 2,
color: colors[index % colors.length],
data: curve.points.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
};
}
/** 同一日多次成交合并成一个标记(图上不叠字) */
export function portfolioMarkers(
fills: ActionRecord[] | undefined,
validDates?: Set<string>
): LwMarker[] {
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>();
for (const f of fills ?? []) {
if (validDates && !validDates.has(f.date)) continue;
const cur = byDate.get(f.date) ?? { BUY: false, SELL: false };
cur[f.signal] = true;
byDate.set(f.date, cur);
}
const out: LwMarker[] = [];
for (const [time, kinds] of byDate) {
if (kinds.BUY) out.push({ time, kind: "BUY", text: "买" });
if (kinds.SELL) out.push({ time, kind: "SELL", text: "卖" });
}
return out.sort((a, b) => (a.time < b.time ? -1 : 1));
}
export function equitySeries(result: BacktestResult): LwSeries[] {
return [
{
key: "equity",
label: "组合净值(元)",
type: "area",
color: CHART.pos,
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
];
}
export function drawdownSeries(result: BacktestResult): LwSeries[] {
return [
{
key: "dd",
label: "回撤(%)",
type: "area",
color: CHART.neg,
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
];
}
export function symbolSeries(curve: SymbolCurve): LwSeries[] {
return [
{
key: "sym",
label: `${curve.symbol} 持仓期累计收益(%)`,
type: "area",
color: CHART.accent,
data: curve.points.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
];
}
export function symbolMarkers(curve: SymbolCurve): LwMarker[] {
const dates = new Set(curve.points.map((p) => p.date));
return (curve.marks ?? [])
.filter((a) => dates.has(a.date))
.map((a) => ({
time: a.date,
kind: a.signal,
text: a.signal === "BUY" ? "买" : "卖",
}));
}
/** 月度收益(柱状) */
export function monthlySeries(result: BacktestResult): LwSeries[] {
return [
{
key: "monthly",
label: "月度收益(%)",
type: "bar",
color: CHART.accent,
data: result.monthly_returns.map((m) => ({
time: `${m.year}-${String(m.month).padStart(2, "0")}-01`,
value: m.return_pct,
})),
lastValueVisible: true,
},
];
}
export const equityValueFormat = (v: number) => fmtNum(v);
+253 -1
View File
@@ -3,6 +3,8 @@
* 大样本研究(全市场)可能耗时数十秒到分钟级,经异步 Job 后台执行,
* 避免 HTTP 长阻塞(AGENT §19)。页面提交后即时返回 job_id,再轮询到终态。
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { apiGet, apiPost } from "./api";
import type { ResearchSpec } from "./types";
@@ -65,16 +67,65 @@ export async function waitJob<T>(
return { status: "timeout", result: null, error: "等待结果超时,请稍后在「实验」页查看归档" };
}
/** 阶段中文名(后端 stage 枚举 → 用户可读) */
/** 阶段中文名(后端 stage 枚举 → 用户可读)。
*
* 必须覆盖每种作业**上报过的全部阶段**:漏一个,JobProgress 只能把裸枚举摆给用户
* —— 实测漏过 `factor_calculation`(/factors 反馈条上直接出现过英文 `factor_calculation`)。
* 新增阶段时请同时补这里的中文名与下方 `STAGE_PIPELINES` 里对应的序列。
*/
export const STAGE_LABEL: Record<string, string> = {
queued: "排队中",
data_loading: "加载行情与因子数据",
factor_calculation: "计算因子值",
selection: "逐择股日选股",
backtesting: "撮合与净值结算",
analysis: "汇总指标与曲线",
done: "完成",
};
/** 作业类型 —— **只列后端真的会异步执行的 kind**:
* - `signals` 不在其中:`POST /api/signals` 是同步接口(没有 /signals/jobs),没有作业阶段可言;
* - `combo`(回测组合)也不单列:它上报的阶段与 backtest 完全一致(证据见下),直接映射到 "backtest"。
*/
export type JobKind = "factor_test" | "backtest" | "selection";
/**
* 每种作业**真实会走**的阶段序列 —— 只放后端真的会上报的阶段,**不含 `queued` / `done`**:
* 「排队中」「已完成」是作业**状态**(由 `phase` 表达,见 JobRunState),不是后端上报的执行阶段,
* 混进圆点序列会让「尚未开始的第一个圆点」和「已结束」看起来像同一条进度。
*
* 为什么必须按作业类型分开:全站共用一条「阶段小圆点」之后,用一张全局表会把因子测试
* 根本不存在的阶段(逐择股日选股 / 撮合与净值结算)也画出来 —— 那正是要避免的
* 「看似有数据的假象」;反过来,后端新阶段如果不在表里,圆点会整排变灰。
*
* 证据(后端上报点,改动前请重新 grep 核对,勿凭印象):
* - factor_test:backend/app/quant/service.py:304 data_loading → :306 factor_calculation → :308 analysis
* - backtest :backend/app/quant/service.py:314 data_loading → :316 backtesting → :319 analysis
* - selection :backend/app/application/services/job_executor.py:156 selection(真的只有这一段)
* - combo(复用 backtest 表):backend/app/application/services/combo_service.py:68 data_loading
* → :70 backtesting → :90 analysis;组合的「逐择股日选股」是在 run_combo_backtest 内部完成的,
* 后端从未单独上报 selection 阶段,所以回测这条线不放 selection 圆点。
*/
export const STAGE_PIPELINES: Record<JobKind, string[]> = {
factor_test: ["data_loading", "factor_calculation", "analysis"],
backtest: ["data_loading", "backtesting", "analysis"],
selection: ["selection"],
};
/** @deprecated 已改为按作业类型取 `STAGE_PIPELINES[kind]`。保留导出仅为兼容旧引用,
* 其值等于回测的圆点序列(不再含 queued/done —— 它们是 phase 不是阶段)。 */
export const STAGE_ORDER = STAGE_PIPELINES.backtest;
/** 取消排队中/执行中的作业(POST /api/jobs/{id}/cancel;已结束的会返回 cancelled=false)。 */
export function cancelJob(
jobId: string,
): Promise<{ job_id: string; status: string; cancelled: boolean }> {
return apiPost<{ job_id: string; status: string; cancelled: boolean }>(
`/jobs/${encodeURIComponent(jobId)}/cancel`,
{},
);
}
/** 提交一个回测组合为异步 Job(POST /api/combos/run,不保存组合)。 */
export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
@@ -90,3 +141,204 @@ export async function submitComboJob(combo: unknown): Promise<JobSubmit> {
export async function runSavedCombo(comboId: string): Promise<JobSubmit> {
return apiPost<JobSubmit>(`/combos/${encodeURIComponent(comboId)}/run`, {});
}
/* ------------------------------------------------------------------ *
* 统一的「提交 → 轮询 → 终态」状态机(useJobRunner)
*
* 为什么要有它:以前每个页面各写一遍 running/jobId/error,结果参差不齐 ——
* 有的页面点了「运行」只在按钮上转圈,用户不知道到底提交没提交、跑到哪一步;
* 有的页面「已用秒数」只在后端阶段变化时才更新,看起来像卡死了(0s 一动不动)。
* 这里把状态、秒表、取消、终态收敛到一处,页面只管渲染。
* ------------------------------------------------------------------ */
export type JobPhase =
| "idle"
| "submitting"
| "queued"
| "running"
| "success"
| "failed"
| "cancelled"
| "timeout";
export interface JobRunState<T> {
phase: JobPhase;
/** 作业号(提交成功后立即拿到;这是「到底提交没提交」的凭据) */
jobId: string;
/** 后端上报的执行阶段(queued/data_loading/selection/backtesting/analysis/done) */
stage: string;
/** 已用毫秒:**每秒自增**,不依赖后端阶段变化,所以界面不会看起来卡住 */
elapsedMs: number;
/** 这次跑的是什么(如「因子测试 · momentum_60」),显示在反馈条上 */
label: string;
/** 批量场景的进度(第 index / total 个),单跑时为 null */
progress: { index: number; total: number } | null;
error: string;
result: T | null;
experimentId: string | null;
/** 提交时刻(本地时间,用于「发起于 hh:mm:ss」) */
startedAt: Date | null;
/** 正在请求取消 */
cancelling: boolean;
}
function emptyState<T>(label = ""): JobRunState<T> {
return {
phase: "idle",
jobId: "",
stage: "",
elapsedMs: 0,
label,
progress: null,
error: "",
result: null,
experimentId: null,
startedAt: null,
cancelling: false,
};
}
export interface JobRunOptions {
/** 反馈条上显示的任务名(如「因子测试 · momentum_60」) */
label?: string;
/** 批量进度(第 index / total 个) */
progress?: { index: number; total: number } | null;
timeoutMs?: number;
}
export interface JobRunner<T> {
state: JobRunState<T>;
/** 提交并轮询到终态;返回终态结果(供页面继续处理,如渲染报告) */
run: (
submit: () => Promise<{ job_id: string }>,
opts?: JobRunOptions,
) => Promise<JobOutcome<T>>;
cancel: () => Promise<void>;
reset: () => void;
}
/**
* 作业运行状态机。
*
* 生命周期:idle → submitting(已点击,正在换 job_id)→ queued/running(轮询中,秒表走)
* → success | failed | cancelled | timeout。**submitting 也是状态**:点了按钮立刻就有
* 反馈文字,不会出现「点了没反应」的空白期。
*/
export function useJobRunner<T>(defaultLabel = ""): JobRunner<T> {
const [state, setState] = useState<JobRunState<T>>(() => emptyState<T>(defaultLabel));
const t0 = useRef(0);
const timer = useRef<number | null>(null);
const alive = useRef(true);
const currentJob = useRef("");
// 后端最近上报的阶段放在 ref 里:终态那一帧要写「最后一个阶段」,但不想让
// `run` 依赖 state(否则每次 state 变化都换一个新函数,页面 effect 会连环触发)。
const stageRef = useRef("");
const stopTick = useCallback(() => {
if (timer.current !== null) {
window.clearInterval(timer.current);
timer.current = null;
}
}, []);
useEffect(
() => () => {
alive.current = false;
if (timer.current !== null) window.clearInterval(timer.current);
},
[],
);
const patch = useCallback((next: Partial<JobRunState<T>>) => {
if (!alive.current) return;
setState((prev) => ({ ...prev, ...next }));
}, []);
const reset = useCallback(() => {
stopTick();
currentJob.current = "";
if (alive.current) setState(emptyState<T>(defaultLabel));
}, [defaultLabel, stopTick]);
const run = useCallback(
async (
submit: () => Promise<{ job_id: string }>,
opts: JobRunOptions = {},
): Promise<JobOutcome<T>> => {
stopTick();
currentJob.current = "";
stageRef.current = "";
t0.current = Date.now();
setState({
...emptyState<T>(opts.label ?? defaultLabel),
phase: "submitting",
label: opts.label ?? defaultLabel,
progress: opts.progress ?? null,
startedAt: new Date(),
});
// 秒表独立于后端阶段:即使后端几秒内不报新阶段,「已用 Xs」也在动
timer.current = window.setInterval(() => {
if (!alive.current) return;
setState((prev) =>
prev.phase === "submitting" || prev.phase === "queued" || prev.phase === "running"
? { ...prev, elapsedMs: Date.now() - t0.current }
: prev,
);
}, 500);
try {
const { job_id } = await submit();
currentJob.current = job_id;
stageRef.current = "queued";
patch({ jobId: job_id, phase: "queued", stage: "queued" });
const out = await waitJob<T>(job_id, opts.timeoutMs ?? 900_000, (info) => {
stageRef.current = info.stage;
patch({
stage: info.stage,
elapsedMs: info.elapsedMs,
phase: info.stage && info.stage !== "queued" ? "running" : "queued",
});
});
const finalPhase: JobPhase =
out.status === "success"
? "success"
: out.status === "cancelled"
? "cancelled"
: out.status === "timeout"
? "timeout"
: "failed";
patch({
phase: finalPhase,
stage: out.status === "success" ? "done" : stageRef.current,
elapsedMs: Date.now() - t0.current,
result: out.result,
experimentId: out.experimentId ?? null,
error: out.error ?? "",
});
return out;
} catch (e) {
const msg = (e as Error).message;
patch({ phase: "failed", error: msg, elapsedMs: Date.now() - t0.current });
return { status: "failed", result: null, error: msg };
} finally {
stopTick();
}
},
[defaultLabel, patch, stopTick],
);
const cancel = useCallback(async () => {
const jobId = currentJob.current;
if (!jobId) return;
patch({ cancelling: true });
try {
await cancelJob(jobId);
// 取消后轮询会在 1.5s 内看到 cancelled 终态;这里先把阶段文字改掉,
// 让用户马上知道「取消请求已发出」而不是等下一个轮询周期。
patch({ stage: "cancelling" });
} finally {
patch({ cancelling: false });
}
}, [patch]);
return { state, run, cancel, reset };
}
+78 -1
View File
@@ -191,6 +191,60 @@ export interface MarkPoint {
}
/** 交易意图与成交记录(Signal ↔ Fill,v3 §20.3)。signal 为空字符串表示组合级提示。 */
/**
* 一次交易意图 / 成交的**结构化理由**(引擎给出的真实数字)。
*
* `code` 是封闭的原因分类(后端 `quant/trade_reasons.py`),前端据此筛选,
* 不去解析 `text`;`data` 里的每个数字都来自引擎当时的计算 —— 界面只展示,不推算。
*/
export interface TradeReason {
code: string;
text: string;
data: {
rank?: number;
total?: number;
top_n?: number;
score?: number;
/** 各因子当时的**原始值**(key = 因子引擎键,可能是参数化键) */
factors?: Record<string, number>;
hold_days?: number;
tmin?: number;
tmax?: number;
price?: number;
budget?: number;
min_commission?: number;
close?: number;
prev_close?: number;
close_prev_ratio?: number;
limit_ratio?: number;
return_pct?: number;
/** false = 该股已不在候选池(被股票池/条件过滤) */
in_pool?: boolean;
[k: string]: unknown;
};
}
/** 原因分类的中文短标签(与后端 REASON_LABELS 对齐) */
export const REASON_LABELS: Record<string, string> = {
buy_enter_topn: "按名次建仓",
buy_defer_filled: "顺延后成交",
buy_skip_limit_up: "涨停未买",
buy_skip_halted: "停牌未买",
buy_skip_no_cash: "现金不足",
buy_skip_min_commission: "不足最低佣金",
sell_drop_topn: "跌出 TopN",
sell_force_tmax: "持有超 Tmax",
sell_rebalance_full: "调仓换仓卖出",
sell_defer_tmin: "Tmin 保护暂留",
sell_defer_halted: "停牌未卖",
sell_defer_limit_down: "跌停未卖",
};
export function reasonLabel(code?: string | null): string {
if (!code) return "—";
return REASON_LABELS[code] ?? code;
}
export interface ActionRecord {
date: string;
symbol: string;
@@ -200,6 +254,17 @@ export interface ActionRecord {
filled: boolean;
reject_reason?: string | null;
price?: number | null;
reason?: TradeReason | null;
}
/** 单个因子的时间序列(回测期内持仓组合加权平均的**原始值**) */
export interface FactorCurve {
name: string;
label: string;
direction: string;
/** 量纲:% / 倍数 / 小数(小数意味着 0.15 = 15%,图上不换算) */
unit?: string | null;
points: CurvePoint[];
}
/** 个股收益率曲线 + 该股买卖点标注 */
@@ -219,11 +284,23 @@ export interface BacktestResult {
monthly_returns: { year: number; month: number; return_pct: number }[];
yearly_returns: { year: number; return_pct: number }[];
positions: { date: string; symbol: string; name?: string | null; weight: number }[];
trades: { entry_date: string; exit_date: string; symbol: string; name?: string | null; entry_price?: number; exit_price?: number; return_pct: number }[];
trades: {
entry_date: string;
exit_date: string;
symbol: string;
name?: string | null;
entry_price?: number;
exit_price?: number;
return_pct: number;
entry_reason?: TradeReason | null;
exit_reason?: TradeReason | null;
}[];
selection_history?: { date: string; symbol: string; name?: string | null; rank: number; score: number }[];
signal_history?: ActionRecord[];
fills?: ActionRecord[];
symbol_curves?: SymbolCurve[];
/** 策略用到的每个因子的时间序列(持仓加权平均原始值):解释买卖依据 */
factor_curves?: FactorCurve[];
turnover_pct: number;
unimplemented: string[];
config_snapshot: Record<string, unknown>;
+54 -1
View File
@@ -93,7 +93,7 @@ def main() -> int:
required = [
"summary", "equity_curve", "drawdown", "monthly_returns", "yearly_returns",
"positions", "trades", "selection_history", "signal_history", "fills",
"symbol_curves", "turnover_pct", "unimplemented", "config_snapshot",
"symbol_curves", "factor_curves", "turnover_pct", "unimplemented", "config_snapshot",
]
missing = [k for k in required if k not in res]
assert not missing, f"结果缺少页面读取的字段:{missing}"
@@ -139,6 +139,59 @@ def main() -> int:
print(f"[contract] config_snapshot.selection={sel}")
print(f"[contract] config_snapshot.price_basis={basis}")
# —— 买卖理由(「用数据说话」的字段契约)——
# 每个买卖点都必须带结构化理由,且关键数字(名次/综合分/因子值)齐全;
# 文案由后端词表生成,前端只展示 —— 这里断言的是「页面要读的字段真的在」。
KNOWN = {
"buy_enter_topn", "buy_defer_filled", "buy_skip_limit_up", "buy_skip_halted",
"buy_skip_no_cash", "buy_skip_min_commission", "sell_drop_topn", "sell_force_tmax",
"sell_defer_tmin", "sell_defer_halted", "sell_defer_limit_down",
}
missing_reason = [a for a in res["signal_history"] if not a.get("reason")]
assert not missing_reason, f"有买卖点没有理由:{missing_reason[:3]}"
bad_code = [a["reason"]["code"] for a in res["signal_history"] if a["reason"]["code"] not in KNOWN]
assert not bad_code, f"出现词表外的理由代码:{sorted(set(bad_code))}"
for a in res["signal_history"]:
r = a["reason"]
assert r["text"], f"{a['date']} {a['symbol']} 理由文案为空"
assert isinstance(r["data"], dict), f"{a['date']} {a['symbol']} 理由缺少结构化数据"
# 成交类理由必须能回答「第几名 / 多少候选 / 综合分 / 因子当时的值」
with_rank = [a for a in res["signal_history"] if isinstance(a["reason"]["data"].get("rank"), int)]
assert with_rank, "没有任何理由给出名次(名次是买入选股的核心依据)"
sample = next(a for a in res["signal_history"] if a["reason"]["code"] == "buy_enter_topn")
sd = sample["reason"]["data"]
assert sd["rank"] >= 1 and sd["total"] >= sd["rank"] and sd["top_n"] >= 1, sd
assert "score" in sd and sd.get("factors"), f"买入理由缺少综合分/因子值:{sd}"
print(f"[contract] 买卖理由 {len(res['signal_history'])} 条全部带理由与数字;样例 "
f"{sample['date']} {sample['symbol']} {sample['reason']['code']} "
f"rank={sd['rank']}/{sd['total']} score={sd['score']} factors={sd['factors']}")
# —— 成交明细两端的理由(页面「为什么买 / 为什么卖」两列)——
trades = res["trades"]
no_entry = [t for t in trades if not t.get("entry_reason")]
no_exit = [t for t in trades if not t.get("exit_reason")]
assert not no_entry, f"{len(no_entry)} 笔成交缺建仓理由"
assert not no_exit, f"{len(no_exit)} 笔成交缺卖出理由"
t0_ = trades[0]
print(f"[contract] 成交明细两端理由齐全({len(trades)} 笔);样例 {t0_['symbol']} "
f"买={t0_['entry_reason']['code']} 卖={t0_['exit_reason']['code']}")
# —— 因子曲线(页面「因子曲线」区块 + 放大页的数据源)——
fcs = res["factor_curves"]
assert fcs, "结果里没有因子曲线(页面会少一整块「买卖依据的水平」)"
for fc in fcs:
assert fc["name"] and fc["label"] and fc["direction"], fc
assert fc["points"], f"因子 {fc['name']} 曲线无数据点"
dates = [p_["date"] for p_ in fc["points"]]
assert dates == sorted(dates), f"因子 {fc['name']} 曲线日期未按时间升序"
assert len(set(dates)) == len(dates), f"因子 {fc['name']} 曲线有重复日期"
used = {f["name"] for f in spec["factors"]} | {"dividend_yield"}
names = {fc["name"] for fc in fcs}
assert names & used, f"因子曲线与本次策略用到的因子对不上:{names} vs {used}"
fc = fcs[0]
print(f"[contract] 因子曲线 {len(fcs)} 条;首条 {fc['name']}({fc['label']},"
f"单位 {fc.get('unit')},{len(fc['points'])} 点,方向 {fc['direction']})")
# —— 未成交意图卡片(signal_history 中 filled=False 且带原因)——
rejects = [a for a in res["signal_history"] if not a["filled"] and a["reject_reason"]]
print(f"[contract] 未成交意图 {len(rejects)} 条,样例:"