Compare commits

..
10 Commits
Author SHA1 Message Date
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
33 changed files with 4435 additions and 450 deletions
+32
View File
@@ -817,6 +817,38 @@ 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`)**没有**作业号与阶段时,如实说明「同步请求、无阶段、
不可取消」,**禁止**合成假作业号或假进度喂给反馈组件。
--- ---
# 28. AI Agent 约束 # 28. AI Agent 约束
+39 -1
View File
@@ -1,4 +1,4 @@
"""Experiment API(Phase 4):列表 / 详情 / 删除 / 一键复跑。""" """Experiment API(Phase 4):列表 / 详情 / 删除(单个 + 批量)/ 一键复跑。"""
from __future__ import annotations from __future__ import annotations
@@ -7,6 +7,7 @@ from datetime import datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response
from pydantic import BaseModel, Field
from app.api.deps import DbSession, ExperimentRepoDep, JobRepoDep from app.api.deps import DbSession, ExperimentRepoDep, JobRepoDep
from app.application.services.job_executor import new_id, run_job_background 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_DEFAULT_LIMIT = 200
LIST_MAX_LIMIT = 1000 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: def _experiment_meta(exp: ExperimentRecord | ExperimentSummary) -> dict:
"""列表项视图(body 形状与旧版一致,仅**新增** data_version / job_id / result_bytes)。 """列表项视图(body 形状与旧版一致,仅**新增** data_version / job_id / result_bytes)。
@@ -85,6 +100,29 @@ def list_experiments(
return [_experiment_meta(e) for e in rows] 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 详情(含完整结果)") @router.get("/{experiment_id}", summary="Experiment 详情(含完整结果)")
def get_experiment(experiment_id: str, experiment_repo: ExperimentRepoDep) -> dict: def get_experiment(experiment_id: str, experiment_repo: ExperimentRepoDep) -> dict:
exp = experiment_repo.get(experiment_id) exp = experiment_repo.get(experiment_id)
+57
View File
@@ -285,6 +285,24 @@ class BacktestSummary(BaseModel):
benchmark_return_pct: float | None = None 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): class Trade(BaseModel):
entry_date: date entry_date: date
exit_date: date exit_date: date
@@ -293,6 +311,12 @@ class Trade(BaseModel):
entry_price: float entry_price: float
exit_price: float exit_price: float
return_pct: float return_pct: float
entry_reason: TradeReason | None = Field(
default=None, description="买入理由(建仓当日引擎给出的结构化理由)"
)
exit_reason: TradeReason | None = Field(
default=None, description="卖出理由(了结当日引擎给出的结构化理由)"
)
class Position(BaseModel): class Position(BaseModel):
@@ -318,6 +342,10 @@ class ActionRecord(BaseModel):
signal=BUY/SELL(策略意图);filled=是否实际成交;reject_reason 给出未成交原因 signal=BUY/SELL(策略意图);filled=是否实际成交;reject_reason 给出未成交原因
(涨停/跌停/无价/现金不足等)。fills = [a for a in signal_history if a.filled]。 (涨停/跌停/无价/现金不足等)。fills = [a for a in signal_history if a.filled]。
`reason` 是**数据化**的为什么:`reject_reason` 只说「没成交」(执行层),
`reason` 同时覆盖成交与未成交(策略层 + 执行层),并带上当时的排名 / 综合分 /
各因子原始值,前端「买卖说明」直接用,不再二次推断。
`name` 为展示增强字段:由服务层按股票池统一回填(未命中则为 None), `name` 为展示增强字段:由服务层按股票池统一回填(未命中则为 None),
引擎自身不感知名称 —— 引擎只处理 symbol,保持纯行情计算职责。 引擎自身不感知名称 —— 引擎只处理 symbol,保持纯行情计算职责。
""" """
@@ -329,6 +357,9 @@ class ActionRecord(BaseModel):
filled: bool filled: bool
reject_reason: str | None = None reject_reason: str | None = None
price: float | None = Field(default=None, description="成交价(fill)或意图参考价") price: float | None = Field(default=None, description="成交价(fill)或意图参考价")
reason: TradeReason | None = Field(
default=None, description="结构化理由(成交与未成交都有;旧归档为 null)"
)
class SymbolCurve(BaseModel): 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): class BacktestResult(BaseModel):
"""标准化回测结果(ARCHITECTURE §14)。前端只依赖该结构。""" """标准化回测结果(ARCHITECTURE §14)。前端只依赖该结构。"""
@@ -375,6 +425,13 @@ class BacktestResult(BaseModel):
default_factory=list, default_factory=list,
description="个股收益率曲线 + 买卖点标注(按期末收益绝对值降序,体积可控)", description="个股收益率曲线 + 买卖点标注(按期末收益绝对值降序,体积可控)",
) )
factor_curves: list[FactorCurve] = Field(
default_factory=list,
description=(
"策略用到的每个因子的时间序列(持仓加权平均原始值):"
"用来解释「买卖依据的那个因子在各时点是什么水平」"
),
)
turnover_pct: float turnover_pct: float
unimplemented: list[str] = Field( unimplemented: list[str] = Field(
default_factory=list, default_factory=list,
+171 -23
View File
@@ -45,8 +45,26 @@ from app.domain.entities.research import (
UniverseSpec, UniverseSpec,
YearlyReturn, 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.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 TRADING_DAYS = 252
@@ -84,18 +102,26 @@ def combine_strategy_scores(
daily: pd.DataFrame, daily: pd.DataFrame,
strategies: list[SelectionStrategyRef], strategies: list[SelectionStrategyRef],
eligibility_fns: list, eligibility_fns: list,
) -> tuple[pd.DataFrame, object]: ) -> tuple[pd.DataFrame, object, dict[str, tuple[FactorDef, pd.DataFrame]]]:
"""多策略 → (综合分面板, 合并合格集闭包)。 """多策略 → (综合分面板, 合并合格集闭包, 原始因子面板)。
综合分面板 index=trade_date, columns=symbol,值为 Borda 秩和(越大越优先)。 综合分面板 index=trade_date, columns=symbol,值为 Borda 秩和(越大越优先)。
合并合格集闭包 `combined(as_of) -> set[symbol] | None`:各策略合格集的并集; 合并合格集闭包 `combined(as_of) -> set[symbol] | None`:各策略合格集的并集;
全部策略都不过滤时返回 None(= 不过滤,交给面板的 dropna 处理)。 全部策略都不过滤时返回 None(= 不过滤,交给面板的 dropna 处理)。
第三个返回值是「策略用到的每个因子的**原始**面板」(key = 因子键):
复合分是 z-score 后的无量纲分,解释不了「股息率到底几厘」,因此买卖理由与
因子曲线必须回到原始值。同一因子被多个策略引用时只算一次。
""" """
# 每个策略一张「复合 zscore 面板」(已按方向加权求和) # 每个策略一张「复合 zscore 面板」(已按方向加权求和)
panels: list[pd.DataFrame] = [] panels: list[pd.DataFrame] = []
raw_panels: dict[str, tuple[FactorDef, pd.DataFrame]] = {}
for ref in strategies: for ref in strategies:
_universe, factors, _conditions = _ref_to_specs(ref) _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) borda = borda_combine(panels)
@@ -117,7 +143,7 @@ def combine_strategy_scores(
out |= s out |= s
return out return out
return borda, combined return borda, combined, raw_panels
# ---------- 持仓区间回测 runner ---------- # ---------- 持仓区间回测 runner ----------
@@ -129,6 +155,9 @@ class _Holding:
entry_date: date entry_date: date
entry_price: float entry_price: float
entry_ts: object = None # pd.Timestamp:按「交易日」计持仓天数用(自然日会跨周末失真) entry_ts: object = None # pd.Timestamp:按「交易日」计持仓天数用(自然日会跨周末失真)
# 建仓理由(结构化):了结时原样写进 Trade.entry_reason,保证「为什么买」在
# 成交明细里能一路带出来 —— 持仓中途没有别的机会把它丢掉。
entry_reason: object = None
_UNIMPLEMENTED_BASE = [ _UNIMPLEMENTED_BASE = [
@@ -144,6 +173,11 @@ _UNIMPLEMENTED_BASE = [
"多策略打分采用 Borda 秩和(各策略 1/名次 求和):不假设不同策略的因子分值可比," "多策略打分采用 Borda 秩和(各策略 1/名次 求和):不假设不同策略的因子分值可比,"
"但极端情况下某策略覆盖极少股票会使其秩和贡献偏大" "但极端情况下某策略覆盖极少股票会使其秩和贡献偏大"
), ),
(
"买卖说明覆盖 signal_history 里的每个买卖点(含涨停/停牌/现金不足等未成交情形);"
"但「当日排名在 TopN 之外、策略本来就无意买入」的候选不计为买卖点,"
"要看完整候选与名次请查选股明细(selection_history)"
),
] ]
@@ -158,6 +192,7 @@ class HoldingBandRunner:
score: pd.DataFrame, score: pd.DataFrame,
close: pd.DataFrame, close: pd.DataFrame,
eligibility_fn=None, eligibility_fn=None,
factor_panels: dict[str, tuple[FactorDef, pd.DataFrame]] | None = None,
) -> None: ) -> None:
self.combo = combo self.combo = combo
self.costs = costs self.costs = costs
@@ -166,11 +201,19 @@ class HoldingBandRunner:
self.close = close.sort_index() self.close = close.sort_index()
self.score = score.reindex(self.close.index).sort_index() self.score = score.reindex(self.close.index).sort_index()
self.eligibility_fn = eligibility_fn self.eligibility_fn = eligibility_fn
# 策略用到的因子原始面板:买卖理由里的因子值、以及因子曲线都从这里取
self.factor_panels = factor_panels or {}
self.selection_history: list[RankedPick] = [] self.selection_history: list[RankedPick] = []
self.signal_history: list[ActionRecord] = [] self.signal_history: list[ActionRecord] = []
self.traded_symbols: list[str] = [] self.traded_symbols: list[str] = []
self._traded: set[str] = set() self._traded: set[str] = set()
self._no_prev_close: 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) equity_rows[d] = _equity(d)
self._mark_curve(d, holdings, cum, curve_rows) 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() equity = pd.Series(equity_rows).sort_index()
return self._to_result(equity, trades, positions, notional, cum, curve_rows) return self._to_result(equity, trades, positions, notional, cum, curve_rows)
@@ -241,6 +285,9 @@ class HoldingBandRunner:
if elig is not None: if elig is not None:
score_d = score_d[score_d.index.isin(elig)] score_d = score_d[score_d.index.isin(elig)]
ranked = score_d.sort_values(ascending=False) 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() top = ranked.head(n).index.tolist()
day = d.date() day = d.date()
for r, sym in enumerate(top, start=1): for r, sym in enumerate(top, start=1):
@@ -249,6 +296,50 @@ class HoldingBandRunner:
) )
return top 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 强制了结(每日) ---- # ---- Tmax 强制了结(每日) ----
def _force_exit_over_max(self, d, day, holdings, cash, trades, tmax) -> float: def _force_exit_over_max(self, d, day, holdings, cash, trades, tmax) -> float:
@@ -261,19 +352,33 @@ class HoldingBandRunner:
continue continue
c = close_d.get(s) c = close_d.get(s)
p = prev_d.get(s) if prev_d is not None else None 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): if _nan(c):
self.signal_history.append( self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False, 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 continue
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0): if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
self.signal_history.append( self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False, 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 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 return cash
# ---- 调仓日:增量调向目标 ---- # ---- 调仓日:增量调向目标 ----
@@ -289,10 +394,13 @@ class HoldingBandRunner:
continue continue
h = holdings[s] h = holdings[s]
held = self._held_trading_days(h.entry_ts, d) held = self._held_trading_days(h.entry_ts, d)
ctx = self._reason_ctx(d, s, top_n=n)
if held < tmin: if held < tmin:
self.signal_history.append( self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False, 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 continue
c = close_d.get(s) c = close_d.get(s)
@@ -300,16 +408,30 @@ class HoldingBandRunner:
if _nan(c): if _nan(c):
self.signal_history.append( self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False, 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 continue
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0): if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
self.signal_history.append( self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False, 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 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 只或现金耗尽 # b) 补买:从 TopN 里挑尚未持有的,按等权目标用可用现金买入,直到 N 只或现金耗尽
current = [s for s in topn if s in holdings and holdings[s].qty > 0] 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: for s in buys:
c = close_d.get(s) c = close_d.get(s)
p = prev_d.get(s) if prev_d is not None else None 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): if _nan(c):
self.signal_history.append( self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False, ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
reject_reason="无行情(停牌),无法买入") reject_reason="无行情(停牌),无法买入",
reason=buy_skipped(BUY_SKIP_HALTED, **ctx))
) )
continue continue
if not _nan(p) and p > 0 and c / p >= _limit_up_ratio(s): if not _nan(p) and p > 0 and c / p >= _limit_up_ratio(s):
self.signal_history.append( self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False, 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 continue
budget = min(per_budget, cash) budget = min(per_budget, cash)
if budget <= 1e-9: if budget <= 1e-9:
self.signal_history.append( self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False, ActionRecord(date=day, symbol=s, signal="BUY", filled=False,
reject_reason="可用现金不足,未成交") reject_reason="可用现金不足,未成交",
reason=buy_skipped(BUY_SKIP_NO_CASH, budget=budget, **ctx))
) )
continue 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: if not ok:
self.signal_history.append( self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="BUY", filled=False, 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 continue
cash -= spent 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) proceeds = h.qty * close_price * (1 - self.costs.slippage_rate)
commission = max(proceeds * self.costs.commission_rate, self.costs.min_commission) commission = max(proceeds * self.costs.commission_rate, self.costs.min_commission)
fee = commission + proceeds * self.costs.stamp_tax_rate fee = commission + proceeds * self.costs.stamp_tax_rate
cash += proceeds - fee cash += proceeds - fee
self.signal_history.append( 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( trades.append(
Trade( Trade(
entry_date=h.entry_date, exit_date=day, symbol=s, entry_date=h.entry_date, exit_date=day, symbol=s,
entry_price=h.entry_price, exit_price=close_price, entry_price=h.entry_price, exit_price=close_price,
return_pct=(close_price / h.entry_price - 1.0) * 100, return_pct=(close_price / h.entry_price - 1.0) * 100,
# 买卖理由跟着成交走:成交明细里「为什么买、为什么卖」都齐
entry_reason=h.entry_reason,
exit_reason=reason,
) )
) )
holdings.pop(s, None) holdings.pop(s, None)
return cash 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) price_in = close_price * (1 + self.costs.slippage_rate)
commission = max(budget * self.costs.commission_rate, self.costs.min_commission) commission = max(budget * self.costs.commission_rate, self.costs.min_commission)
invest = budget - commission invest = budget - commission
@@ -394,10 +535,13 @@ class HoldingBandRunner:
pv = prev.get(s) if prev is not None else float("nan") pv = prev.get(s) if prev is not None else float("nan")
if _nan(pv) or pv <= 0: if _nan(pv) or pv <= 0:
self._no_prev_close.add(s) 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) notional.append(budget)
self.signal_history.append( 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: if s not in self._traded:
self._traded.add(s) self._traded.add(s)
@@ -515,6 +659,7 @@ class HoldingBandRunner:
signal_history=self.signal_history, signal_history=self.signal_history,
fills=[a for a in self.signal_history if a.filled], fills=[a for a in self.signal_history if a.filled],
symbol_curves=curves, 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), turnover_pct=round(sum(notional) / max(init, 1) * 100, 2),
unimplemented=self._unimplemented(), unimplemented=self._unimplemented(),
config_snapshot={}, # 由服务层填入 ComboRunSpec(含策略+成本快照) config_snapshot={}, # 由服务层填入 ComboRunSpec(含策略+成本快照)
@@ -567,10 +712,13 @@ def run_combo_backtest(
可为 None 表示该策略无额外过滤);由服务层用既有 selection 求值器装配。 可为 None 表示该策略无额外过滤);由服务层用既有 selection 求值器装配。
返回结果的 config_snapshot 由调用方填入 ComboRunSpec(含策略+成本快照)以保证可复现。 返回结果的 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() close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
runner = HoldingBandRunner( runner = HoldingBandRunner(
combo=combo, costs=costs, score=score, close=close, eligibility_fn=combined_elig, combo=combo, costs=costs, score=score, close=close, eligibility_fn=combined_elig,
factor_panels=factor_panels,
) )
result = runner.run() result = runner.run()
# 固化可复现规格(AGENT.md §21):组合参数 + 当时各策略定义 + 当时成本/复权 # 固化可复现规格(AGENT.md §21):组合参数 + 当时各策略定义 + 当时成本/复权
+16 -3
View File
@@ -57,11 +57,24 @@ def build_factor_panels(
daily: pd.DataFrame, factor_specs daily: pd.DataFrame, factor_specs
) -> list[tuple[str, pd.DataFrame, float, str]]: ) -> list[tuple[str, pd.DataFrame, float, str]]:
"""按 spec.factors 计算面板与权重(因子不存在即报错)。""" """按 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: for fs in factor_specs:
defn: FactorDef
defn, panel = compute_factor(fs.name, daily) defn, panel = compute_factor(fs.name, daily)
panels.append((fs.name, panel, fs.weight, defn.direction)) panels.append((defn, panel, fs.weight))
return panels return panels
+14 -4
View File
@@ -10,9 +10,10 @@ from typing import Protocol
import pandas as pd import pandas as pd
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec 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.factors import FactorError, get_factor
from app.quant.local_engine import TopKBacktestRunner, run_spec_factor_test 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 收盘撮合 / 前瞻收益) # LocalEngine 路径恒需 close(TopK 收盘撮合 / 前瞻收益)
_CLOSE = {"close"} _CLOSE = {"close"}
@@ -73,7 +74,16 @@ class LocalEngine:
def run_backtest( def run_backtest(
self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None self, daily: pd.DataFrame, spec: ResearchSpec, eligibility_fn=None
) -> BacktestResult: ) -> 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() 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()
+15
View File
@@ -117,6 +117,7 @@ class FactorDef:
param_specs: tuple[ParamSpec, ...] = () # 可编辑参数与约束(供目录/界面) param_specs: tuple[ParamSpec, ...] = () # 可编辑参数与约束(供目录/界面)
source: str = "builtin" # builtin(代码注册表)| custom(目录里创建的参数化实例) source: str = "builtin" # builtin(代码注册表)| custom(目录里创建的参数化实例)
label: str = "" # 中文显示名(含参数),如「动量(窗口 90,越高越好)」 label: str = "" # 中文显示名(含参数),如「动量(窗口 90,越高越好)」
unit: str | None = None # 因子值的量纲(% / 倍数 / 小数),供图表坐标轴与说明用
@property @property
def display(self) -> str: def display(self) -> str:
@@ -141,6 +142,11 @@ class FactorTemplate:
requires: tuple[str, ...] = ("close",) requires: tuple[str, ...] = ("close",)
frequency: str = "daily" frequency: str = "daily"
direction_default: str = DIRECTION_HIGHER 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 lookback_of: Callable[[Mapping[str, Any]], int] | None = None
check: Callable[[Mapping[str, Any]], str | None] | None = None # 跨参数约束 check: Callable[[Mapping[str, Any]], str | None] | None = None # 跨参数约束
instances: tuple[tuple[str, Mapping[str, Any]], ...] = () # ((历史名, 参数), ...) instances: tuple[tuple[str, Mapping[str, Any]], ...] = () # ((历史名, 参数), ...)
@@ -366,6 +372,7 @@ def build_factor_def(
param_specs=template.specs(), param_specs=template.specs(),
source=source, source=source,
label=label_of(checked), label=label_of(checked),
unit=template.unit,
) )
@@ -496,6 +503,7 @@ register_template(
description="过去 {window} 个交易日收益率", description="过去 {window} 个交易日收益率",
formula="close / close.shift({window}) - 1", formula="close / close.shift({window}) - 1",
brief="动量:强者延续,适合趋势延续环境;窗口越短越敏感、越长越稳。", brief="动量:强者延续,适合趋势延续环境;窗口越短越敏感、越长越稳。",
unit="小数",
fn=lambda fields, params: _rolling_return(fields["close"], params[P_WINDOW]), fn=lambda fields, params: _rolling_return(fields["close"], params[P_WINDOW]),
param_specs=(_window_spec(),), param_specs=(_window_spec(),),
lookback_of=lambda params: params[P_WINDOW], lookback_of=lambda params: params[P_WINDOW],
@@ -514,6 +522,7 @@ register_template(
description="过去 {window} 个交易日收益率波动率", description="过去 {window} 个交易日收益率波动率",
formula="std(pct_change, {window})", formula="std(pct_change, {window})",
brief="低波动防御:近段波动小的股票抗跌,弱市/熊市阶段相对占优(方向越低越好)。", brief="低波动防御:近段波动小的股票抗跌,弱市/熊市阶段相对占优(方向越低越好)。",
unit="小数",
fn=lambda fields, params: _rolling_vol(fields["close"], params[P_WINDOW]), fn=lambda fields, params: _rolling_vol(fields["close"], params[P_WINDOW]),
param_specs=(_window_spec(),), param_specs=(_window_spec(),),
direction_default=DIRECTION_LOWER, direction_default=DIRECTION_LOWER,
@@ -532,6 +541,7 @@ register_template(
description="收盘价相对 {window} 日最高价的接近程度", description="收盘价相对 {window} 日最高价的接近程度",
formula="close / rolling_max(high, {window})", formula="close / rolling_max(high, {window})",
brief="贴近 n 日高点(接近新高):趋势确认型强势股,常与动量互补;需配合市场热度判断。", brief="贴近 n 日高点(接近新高):趋势确认型强势股,常与动量互补;需配合市场热度判断。",
unit="倍数",
fn=lambda fields, params: fields["close"] / fields["high"].rolling(params[P_WINDOW]).max(), fn=lambda fields, params: fields["close"] / fields["high"].rolling(params[P_WINDOW]).max(),
param_specs=(_window_spec(),), param_specs=(_window_spec(),),
requires=("close", "high"), requires=("close", "high"),
@@ -559,6 +569,7 @@ register_template(
description="量比:{fast} 日均量 / {slow} 日均量", description="量比:{fast} 日均量 / {slow} 日均量",
formula="mean(volume, {fast}) / mean(volume, {slow})", formula="mean(volume, {fast}) / mean(volume, {slow})",
brief="量比放大提示资金关注(短线活跃型);高换手也伴随更高波动,注意与波动因子搭配。", brief="量比放大提示资金关注(短线活跃型);高换手也伴随更高波动,注意与波动因子搭配。",
unit="倍数",
fn=lambda fields, params: ( fn=lambda fields, params: (
fields["volume"].rolling(params[P_FAST]).mean() fields["volume"].rolling(params[P_FAST]).mean()
/ fields["volume"].rolling(params[P_SLOW]).mean() / fields["volume"].rolling(params[P_SLOW]).mean()
@@ -598,6 +609,7 @@ register_template(
description="{window} 日均线乖离率", description="{window} 日均线乖离率",
formula="(close - ma(close, {window})) / ma(close, {window})", formula="(close - ma(close, {window})) / ma(close, {window})",
brief="均线乖离:上行趋势中正乖离偏强;乖离过大易回落,需警惕过热。", brief="均线乖离:上行趋势中正乖离偏强;乖离过大易回落,需警惕过热。",
unit="小数",
fn=lambda fields, params: ( fn=lambda fields, params: (
(fields["close"] - fields["close"].rolling(params[P_WINDOW]).mean()) (fields["close"] - fields["close"].rolling(params[P_WINDOW]).mean())
/ fields["close"].rolling(params[P_WINDOW]).mean() / fields["close"].rolling(params[P_WINDOW]).mean()
@@ -615,6 +627,7 @@ register_template(
description="短期反转:过去 {window} 日收益率取负", description="短期反转:过去 {window} 日收益率取负",
formula="-1 * (close / close.shift({window}) - 1)", formula="-1 * (close / close.shift({window}) - 1)",
brief="短期反转:前期跌幅大的超跌反弹机会,适合震荡/修复行情。", brief="短期反转:前期跌幅大的超跌反弹机会,适合震荡/修复行情。",
unit="小数",
fn=lambda fields, params: -1.0 * _rolling_return(fields["close"], params[P_WINDOW]), fn=lambda fields, params: -1.0 * _rolling_return(fields["close"], params[P_WINDOW]),
param_specs=(_window_spec(),), param_specs=(_window_spec(),),
lookback_of=lambda params: params[P_WINDOW], lookback_of=lambda params: params[P_WINDOW],
@@ -641,6 +654,7 @@ register_template(
"建议配合 dv_ratio 上限过滤与盈利质量条件使用。" "建议配合 dv_ratio 上限过滤与盈利质量条件使用。"
), ),
fn=lambda fields, params: fields["dv_ratio"], fn=lambda fields, params: fields["dv_ratio"],
unit="%",
requires=("dv_ratio",), requires=("dv_ratio",),
lookback_of=lambda params: 0, # 时点截面值,无滚动窗口 lookback_of=lambda params: 0, # 时点截面值,无滚动窗口
instances=(("dividend_yield", {P_DIRECTION: DIRECTION_HIGHER}),), instances=(("dividend_yield", {P_DIRECTION: DIRECTION_HIGHER}),),
@@ -654,6 +668,7 @@ register_template(
description="股息率 TTM(近 12 个月滚动现金分红 / 总市值 × 100,%)", description="股息率 TTM(近 12 个月滚动现金分红 / 总市值 × 100,%)",
formula="dv_ttm(Tushare daily_basic,逐日时点值)", formula="dv_ttm(Tushare daily_basic,逐日时点值)",
brief="同股息率,但口径为 TTM;与 dv_ratio 多数日期取值一致,可作交叉验证。", brief="同股息率,但口径为 TTM;与 dv_ratio 多数日期取值一致,可作交叉验证。",
unit="%",
fn=lambda fields, params: fields["dv_ttm"], fn=lambda fields, params: fields["dv_ttm"],
requires=("dv_ttm",), requires=("dv_ttm",),
lookback_of=lambda params: 0, lookback_of=lambda params: 0,
+230 -24
View File
@@ -34,6 +34,7 @@ from app.domain.entities.research import (
ResearchSpec, ResearchSpec,
SymbolCurve, SymbolCurve,
Trade, Trade,
TradeReason,
YearlyReturn, YearlyReturn,
) )
from app.quant.composite import ( # noqa: F401 —— re-export(模块化后旧引用仍可用) 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, cross_sectional_zscore,
) )
from app.quant.evaluation import run_factor_test from app.quant.evaluation import run_factor_test
from app.quant.factors import FactorDef
from app.quant.portfolio import ( from app.quant.portfolio import (
allocate_with_max_position, allocate_with_max_position,
equal_weight_budget, equal_weight_budget,
unimplemented_notes, 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 TRADING_DAYS = 252
@@ -209,6 +227,7 @@ class TopKBacktestRunner:
score: pd.DataFrame, score: pd.DataFrame,
close: pd.DataFrame, close: pd.DataFrame,
eligibility_fn=None, eligibility_fn=None,
factor_panels: dict[str, tuple[FactorDef, pd.DataFrame]] | None = None,
) -> None: ) -> None:
self.spec = spec self.spec = spec
close = close.copy() close = close.copy()
@@ -221,12 +240,24 @@ class TopKBacktestRunner:
# 条件过滤(可选):(as_of: date) -> set[symbol] | None # 条件过滤(可选):(as_of: date) -> set[symbol] | None
# 由 Service 注入(复用 selection.eligible_symbols),保证回测与选股同一套求值逻辑 # 由 Service 注入(复用 selection.eligible_symbols),保证回测与选股同一套求值逻辑
self.eligibility_fn = eligibility_fn 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) # M9-2:调仓意图与信号/成交记录(v3 §20.3/§22.3)
self.selection_history: list[RankedPick] = [] self.selection_history: list[RankedPick] = []
self.signal_history: list[ActionRecord] = [] self.signal_history: list[ActionRecord] = []
# 当前候选池(择股日刷新):current_ranked 为全市场可评分排序,current_pool = 前 n # 当前候选池(择股日刷新):current_ranked 为全市场可评分排序,current_pool = 前 n
self.current_ranked: list[str] = [] self.current_ranked: list[str] = []
self.current_pool: 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_symbols: list[str] = []
self._traded: set[str] = set() self._traded: set[str] = set()
@@ -265,6 +296,9 @@ class TopKBacktestRunner:
shares: dict[str, float] = {} shares: dict[str, float] = {}
entry_date: dict[str, date] = {} entry_date: dict[str, date] = {}
entry_price: dict[str, float] = {} entry_price: dict[str, float] = {}
# 建仓理由存进持仓结构:持有期间没有别的机会带上它,卖出成交时原样写进
# Trade.entry_reason,成交明细里「为什么买、为什么卖」才都齐
entry_reason: dict[str, TradeReason] = {}
equity_rows: dict[pd.Timestamp, float] = {} equity_rows: dict[pd.Timestamp, float] = {}
trades: list[Trade] = [] trades: list[Trade] = []
positions: list[Position] = [] positions: list[Position] = []
@@ -273,6 +307,8 @@ class TopKBacktestRunner:
# 个股收益曲线:cum = 该股「持仓期间」的累计净值(1.0 = 未涨未跌) # 个股收益曲线:cum = 该股「持仓期间」的累计净值(1.0 = 未涨未跌)
cum: dict[str, float] = {} cum: dict[str, float] = {}
curve_rows: dict[str, list[CurvePoint]] = {} 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: def _value(d: pd.Timestamp) -> float:
total = cash total = cash
@@ -296,15 +332,19 @@ class TopKBacktestRunner:
# 上一次调仓挂起的顺延单作废(只在两次调仓之间有效) # 上一次调仓挂起的顺延单作废(只在两次调仓之间有效)
pending = [] pending = []
cash = self._rebalance( cash = self._rebalance(
d, cash, shares, entry_date, entry_price, trades, positions, notional, d, cash, shares, entry_date, entry_price, entry_reason, trades, positions,
pending, notional, pending,
) )
elif 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) equity_rows[d] = _value(d)
# 3) 建仓当日补「基准点」:成交在当日收盘、收益自次日起计;该点使 BUY 标注 # 3) 建仓当日补「基准点」:成交在当日收盘、收益自次日起计;该点使 BUY 标注
# 能精确落在曲线上,也让多段持仓的分段起点可见(见 _mark_curve_dates) # 能精确落在曲线上,也让多段持仓的分段起点可见(见 _mark_curve_dates)
self._mark_curve_dates(d, shares, cum, curve_rows) 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() equity = pd.Series(equity_rows).sort_index()
return self._to_result(equity, trades, positions, notional, cum, curve_rows) return self._to_result(equity, trades, positions, notional, cum, curve_rows)
@@ -319,38 +359,131 @@ class TopKBacktestRunner:
eligible = self.eligibility_fn(d.date()) eligible = self.eligibility_fn(d.date())
if eligible is not None: if eligible is not None:
score_d = score_d[score_d.index.isin(eligible)] 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 n = self.spec.selection.top_n
pool = ranked[:n] pool = order[:n]
day = d.date() day = d.date()
for rank, sym in enumerate(pool, start=1): for rank, sym in enumerate(pool, start=1):
self.selection_history.append( self.selection_history.append(
RankedPick(date=day, symbol=sym, rank=rank, score=round(float(score_d[sym]), 6)) 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 生效) ---- # ---- 调仓(t 收盘执行,自 t+1 生效) ----
def _rebalance( 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] close_d = self.close.loc[d]
prev_d = self.prev_close.loc[d] prev_d = self.prev_close.loc[d]
day = d.date() day = d.date()
n = self.spec.selection.top_n # 候选池大小:理由里的 TopN 口径
# 1) 卖出:逐持仓记录 SELL 意图与实际成交(跌停/无价则保留并说明) # 1) 卖出:逐持仓记录 SELL 意图与实际成交(跌停/无价则保留并说明)
for s in [s for s in shares if shares[s] > 0]: for s in [s for s in shares if shares[s] > 0]:
c, p = close_d[s], prev_d[s] 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): if _nan(c):
self.signal_history.append( self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False, 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 # 停牌无价:保留 continue # 停牌无价:保留
if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0): if not _nan(p) and p > 0 and c / p <= 1.0 - (_limit_up_ratio(s) - 1.0):
self.signal_history.append( self.signal_history.append(
ActionRecord(date=day, symbol=s, signal="SELL", filled=False, 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 # 跌停无法卖出:保留到下一调仓 continue # 跌停无法卖出:保留到下一调仓
qty = shares[s] qty = shares[s]
@@ -358,8 +491,23 @@ class TopKBacktestRunner:
commission = max(proceeds * self.costs.commission_rate, self.costs.min_commission) commission = max(proceeds * self.costs.commission_rate, self.costs.min_commission)
fee = commission + proceeds * self.costs.stamp_tax_rate fee = commission + proceeds * self.costs.stamp_tax_rate
cash += proceeds - fee 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( 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( trades.append(
Trade( Trade(
@@ -369,11 +517,15 @@ class TopKBacktestRunner:
entry_price=entry_price[s], entry_price=entry_price[s],
exit_price=float(c), exit_price=float(c),
return_pct=(float(c) / entry_price[s] - 1.0) * 100, return_pct=(float(c) / entry_price[s] - 1.0) * 100,
# 买卖理由跟着成交走:明细里「为什么买、为什么卖」两端齐全
entry_reason=entry_reason.get(s),
exit_reason=sell_reason,
) )
) )
shares[s] = 0.0 shares[s] = 0.0
entry_date.pop(s, None) entry_date.pop(s, None)
entry_price.pop(s, None) entry_price.pop(s, None)
entry_reason.pop(s, None)
# 2) 买入意图:候选池(= selection_history 记录的那批) # 2) 买入意图:候选池(= selection_history 记录的那批)
picks = list(self.current_pool) 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] c, p = close_d[sym], prev_d[sym]
if _nan(c): if _nan(c):
return False, "无行情(停牌),无法买入" return False, "无行情(停牌),无法买入", BUY_SKIP_HALTED
if _nan(p) or p <= 0: if _nan(p) or p <= 0:
# 无有效前收(数据窗口起点 / 长期停牌后复牌):无法判定涨停 → 按可买处理。 # 无有效前收(数据窗口起点 / 长期停牌后复牌):无法判定涨停 → 按可买处理。
# 这里不计数:_buyable 是纯探测函数(替补扫描会重复调用同一标的), # 这里不计数:_buyable 是纯探测函数(替补扫描会重复调用同一标的),
# 计数放在真实成交路径 `_execute_buy`,避免把探测次数报成买入次数。 # 计数放在真实成交路径 `_execute_buy`,避免把探测次数报成买入次数。
return True, None return True, None, None
if c / p >= _limit_up_ratio(sym): if c / p >= _limit_up_ratio(sym):
return False, "涨停,无法追买" return False, "涨停,无法追买", BUY_SKIP_LIMIT_UP
return True, None return True, None, None
# 目标名单:默认 = 池内前 x;allow_substitute=True 时从全市场排序继续往下找 # 目标名单:默认 = 池内前 x;allow_substitute=True 时从全市场排序继续往下找
targets: list[str] = [] targets: list[str] = []
@@ -412,7 +569,7 @@ class TopKBacktestRunner:
for sym in self.current_ranked: for sym in self.current_ranked:
if len(targets) >= self.spec.selection.x: if len(targets) >= self.spec.selection.x:
break break
ok, _ = _buyable(sym) ok, _, _code = _buyable(sym)
if ok: if ok:
targets.append(sym) targets.append(sym)
else: else:
@@ -437,17 +594,24 @@ class TopKBacktestRunner:
for s in targets: for s in targets:
budget = spends[s] budget = spends[s]
ctx = self._reason_ctx(d, s, top_n=n)
if budget <= 1e-9: if budget <= 1e-9:
# 分配额过小(可用现金≈0 或上限约束):不成交且无额度可顺延,如实留痕 # 分配额过小(可用现金≈0 或上限约束):不成交且无额度可顺延,如实留痕
self.signal_history.append( self.signal_history.append(
ActionRecord( ActionRecord(
date=day, symbol=s, signal="BUY", filled=False, date=day, symbol=s, signal="BUY", filled=False,
reject_reason="分配额不足(可用现金≈0),未成交", reject_reason="分配额不足(可用现金≈0),未成交",
reason=self._buy_skip_reason(
BUY_SKIP_NO_CASH, symbol=s, ctx=ctx, budget=budget
),
) )
) )
continue continue
ok, reason = _buyable(s) ok, reason, code = _buyable(s)
if not ok: 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: if sel.defer_buy:
# 顺延:挂单到之后首个可成交交易日(本次不成交,资金留现金) # 顺延:挂单到之后首个可成交交易日(本次不成交,资金留现金)
pending_specs.append((s, reason)) pending_specs.append((s, reason))
@@ -455,6 +619,7 @@ class TopKBacktestRunner:
ActionRecord( ActionRecord(
date=day, symbol=s, signal="BUY", filled=False, date=day, symbol=s, signal="BUY", filled=False,
reject_reason=f"{reason},顺延到之后首个可成交日买入", reject_reason=f"{reason},顺延到之后首个可成交日买入",
reason=skip_reason,
) )
) )
else: else:
@@ -462,16 +627,26 @@ class TopKBacktestRunner:
ActionRecord( ActionRecord(
date=day, symbol=s, signal="BUY", filled=False, date=day, symbol=s, signal="BUY", filled=False,
reject_reason=reason or "不可买入", reject_reason=reason or "不可买入",
reason=skip_reason,
) )
) )
continue 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( 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( self.signal_history.append(
ActionRecord( ActionRecord(
date=day, symbol=s, signal="BUY", filled=False, date=day, symbol=s, signal="BUY", filled=False,
reject_reason="预算不足以覆盖最低佣金,未成交", reject_reason="预算不足以覆盖最低佣金,未成交",
reason=self._buy_skip_reason(
BUY_SKIP_MIN_COMMISSION, symbol=s, ctx=ctx, budget=budget
),
) )
) )
continue continue
@@ -484,10 +659,17 @@ class TopKBacktestRunner:
for sym in picks: for sym in picks:
if sym in set(targets): if sym in set(targets):
continue continue
_ok, reason = _buyable(sym) _ok, reason, code = _buyable(sym)
if code is None:
code = BUY_SKIP_NO_CASH # 可买却未入选目标:资金分配已给别人
self.signal_history.append( self.signal_history.append(
ActionRecord(date=day, symbol=sym, signal="BUY", filled=False, 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) 记录调仓后仓位 # 3) 记录调仓后仓位
@@ -510,7 +692,8 @@ class TopKBacktestRunner:
return cash return cash
def _execute_buy( 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: ) -> bool:
"""按收盘价 + 滑点买入;佣金(含最低佣金)从投入资金中扣除。 """按收盘价 + 滑点买入;佣金(含最低佣金)从投入资金中扣除。
@@ -527,13 +710,15 @@ class TopKBacktestRunner:
shares[s] = shares.get(s, 0.0) + invest / price_in shares[s] = shares.get(s, 0.0) + invest / price_in
entry_date[s] = d.date() entry_date[s] = d.date()
entry_price[s] = price_in 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") prev = self.prev_close.at[d, s] if d in self.prev_close.index else float("nan")
if _nan(prev) or prev <= 0: if _nan(prev) or prev <= 0:
self._no_prev_close_symbols.add(s) # 无前收→涨停不可判定,如实记入标注 self._no_prev_close_symbols.add(s) # 无前收→涨停不可判定,如实记入标注
notional.append(budget) notional.append(budget)
self.signal_history.append( self.signal_history.append(
ActionRecord(date=d.date(), symbol=s, signal="BUY", filled=True, 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: if s not in self._traded:
self._traded.add(s) self._traded.add(s)
@@ -542,7 +727,9 @@ class TopKBacktestRunner:
# ---- 顺延买入(defer_buy):之后逐日重试 ---- # ---- 顺延买入(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] close_d = self.close.loc[d]
prev_d = self.prev_close.loc[d] prev_d = self.prev_close.loc[d]
remaining: list[PendingBuy] = [] remaining: list[PendingBuy] = []
@@ -560,8 +747,18 @@ class TopKBacktestRunner:
if budget <= 1e-9: if budget <= 1e-9:
remaining.append(order) # 无可用现金(理论上不会发生) remaining.append(order) # 无可用现金(理论上不会发生)
continue 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( 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) # 预算不足:保留挂单(下日现金可能已变化) remaining.append(order) # 预算不足:保留挂单(下日现金可能已变化)
continue continue
@@ -686,6 +883,8 @@ class TopKBacktestRunner:
signal_history=self.signal_history, signal_history=self.signal_history,
fills=[a for a in self.signal_history if a.filled], fills=[a for a in self.signal_history if a.filled],
symbol_curves=curves, 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), turnover_pct=round(sum(notional) / max(init, 1) * 100, 2),
unimplemented=self._unimplemented(curve_note), unimplemented=self._unimplemented(curve_note),
config_snapshot=self.spec.model_dump(mode="json"), config_snapshot=self.spec.model_dump(mode="json"),
@@ -728,6 +927,13 @@ class TopKBacktestRunner:
def _unimplemented(self, curve_note: str | None = None) -> list[str]: def _unimplemented(self, curve_note: str | None = None) -> list[str]:
notes = list(_DEFAULT_UNIMPLEMENTED) + unimplemented_notes(self.spec.portfolio) notes = list(_DEFAULT_UNIMPLEMENTED) + unimplemented_notes(self.spec.portfolio)
# 如实说明买卖理由的覆盖边界:每个买卖点(含涨停/停牌/现金不足等未成交)都有理由,
# 但「排名在 TopN 之外、策略本来就无意买入」的候选不算买卖点(看选股明细即可)。
notes.append(
"买卖说明覆盖 signal_history 里的每个买卖点(含涨停/停牌/现金不足等未成交情形);"
"「当日排名在 TopN 之外、策略本来就无意买入」的候选不计为买卖点,"
"要看完整候选与名次请查选股明细(selection_history)"
)
if self._no_prev_close_symbols: if self._no_prev_close_symbols:
notes.append( notes.append(
f"有 {len(self._no_prev_close_symbols)} 只标的成交时缺少上一有效收盘价," f"有 {len(self._no_prev_close_symbols)} 只标的成交时缺少上一有效收盘价,"
+8 -4
View File
@@ -21,10 +21,10 @@ from pathlib import Path
import pandas as pd import pandas as pd
from app.domain.entities.research import BacktestResult, FactorTestReport, ResearchSpec 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.engine import QuantEngine
from app.quant.local_engine import ( from app.quant.local_engine import (
TopKBacktestRunner, TopKBacktestRunner,
build_factor_panels,
composite_score, composite_score,
run_spec_factor_test, run_spec_factor_test,
) )
@@ -69,8 +69,10 @@ class QlibEngine(QuantEngine):
`eligibility_fn`(选股条件/时点 ST 过滤)必须透传,否则条件与 `eligibility_fn`(选股条件/时点 ST 过滤)必须透传,否则条件与
`exclude_st` 在 Qlib 引擎下会被**静默忽略**(AGENT.md §24 禁止假装支持)。 `exclude_st` 在 Qlib 引擎下会被**静默忽略**(AGENT.md §24 禁止假装支持)。
""" """
panels = build_factor_panels(daily, spec.factors) # 与 LocalEngine 同口径:复合分与买卖理由/因子曲线用**同一张**原始因子面板
score = composite_score(panels) 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) self.qlib_dir.mkdir(parents=True, exist_ok=True)
uri = build_qlib_dataset(daily, self.qlib_dir) uri = build_qlib_dataset(daily, self.qlib_dir)
@@ -85,7 +87,9 @@ class QlibEngine(QuantEngine):
) )
close = close.sort_index() 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") result.config_snapshot = spec.model_dump(mode="json")
note = _ENGINE_NOTE note = _ENGINE_NOTE
result.unimplemented = [note, *result.unimplemented] 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 from datetime import date, datetime
import pytest import pytest
from app.api.experiments import BULK_DELETE_MAX_IDS
from app.application.services import job_executor as je from app.application.services import job_executor as je
from app.application.services.job_executor import execute_job from app.application.services.job_executor import execute_job
from app.domain.entities.research import ( from app.domain.entities.research import (
ExperimentRecord,
JobRecord, JobRecord,
JobStatus, JobStatus,
ResearchSpec, ResearchSpec,
@@ -266,6 +268,53 @@ class TestJobsApi:
with TestClient(app) as client: with TestClient(app) as client:
assert client.get("/api/jobs/JOB-NOPE").status_code == 404 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: def test_experiments_list_and_detail(self, seeded_api_db) -> None:
with TestClient(app) as client: with TestClient(app) as client:
# 先跑一个 job 生成归档,再验证列表与详情 # 先跑一个 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」;若曲线因体积预算被裁剪,或该快照产生于 **归档完整度会明示**:归档页顶部标注「归档完整 N/N」;若曲线因体积预算被裁剪,或该快照产生于
完整存档上线之前(个股曲线只有 60 只),页面直接显示「**归档不完整**」并给出差异与「以此参数重跑」入口。 完整存档上线之前(个股曲线只有 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`), **图表**:全站统一使用 **TradingView Lightweight Charts**(`components/charts/LwChart.tsx`),
ECharts 已从依赖中移除。买卖点标记(▲绿=买入 / ▼红=卖出)只落在该 series 真实存在的交易日上, ECharts 已从依赖中移除。买卖点标记(▲绿=买入 / ▼红=卖出)只落在该 series 真实存在的交易日上,
并按时间升序提交(Lightweight Charts 的硬约束),因此不会出现标记丢失或错位。 并按时间升序提交(Lightweight Charts 的硬约束),因此不会出现标记丢失或错位。
@@ -524,6 +558,16 @@ cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace
# 只验接口与页面(跳过回测 Job):加 --skip-job # 只验接口与页面(跳过回测 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 界面规范与对齐自检(控件尺寸 / 输入友好) #### 6.3.2 界面规范与对齐自检(控件尺寸 / 输入友好)
界面不是"能看就行":控件错位、尺寸与内容不匹配、点不中、报错说不清,都会直接变成操作错误。 界面不是"能看就行":控件错位、尺寸与内容不匹配、点不中、报错说不清,都会直接变成操作错误。
@@ -625,12 +669,12 @@ Agent 能力边界(10 个内置受控工具,只读 + 受控写库):
```bash ```bash
cd backend cd backend
uv run ruff check app tests && uv run ruff format --check app tests uv run ruff check app tests && uv run ruff format --check app tests
uv run pytest # 全量测试(每个里程碑提交前均须通过,当前 500 条) uv run pytest # 全量测试(每个里程碑提交前均须通过,当前 524 条)
cd frontend/web cd frontend/web
pnpm run typecheck # tsc --noEmit,0 error pnpm run typecheck # tsc --noEmit,0 error
pnpm run test:charts # 图表标记逻辑单测(7 条,node --test,无需额外依赖) 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,用于验证「页面实现」与「后端字段」不漂移): **端到端契约脚本**(会真实提交回测 Job,用于验证「页面实现」与「后端字段」不漂移):
@@ -638,7 +682,7 @@ pnpm run build # 13 条路由,含 /strategies /backtest /e
```bash ```bash
cd backend cd backend
PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py # 选股策略/字段库/因子目录+参数化/公共配置/回测组合库/归档链路(145 项 skip-job,含真实组合回测更多,约 5 分钟) 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_ui_alignment.py # UI 对齐与控件一致性(8 个页面 × 2 宽度 = 160 项,约 3 分钟)
python3 ../scripts/verify_unit_conversion.py # 字段库单位:只能在给定范围内选 + 界面单位⇄基准单位换算(约 2 分钟) python3 ../scripts/verify_unit_conversion.py # 字段库单位:只能在给定范围内选 + 界面单位⇄基准单位换算(约 2 分钟)
python3 ../scripts/verify_factor_params.py # 因子参数化:暴露真实参数/界面新建参数化因子/越界拒绝/停用不影响历史解析(约 3 分钟) python3 ../scripts/verify_factor_params.py # 因子参数化:暴露真实参数/界面新建参数化因子/越界拒绝/停用不影响历史解析(约 3 分钟)
@@ -651,7 +695,9 @@ python3 ../scripts/verify_factor_params.py # 因
归档、Agent 工具白名单与编排、LLM 配置加载、API 端到端、**策略说明推导** 归档、Agent 工具白名单与编排、LLM 配置加载、API 端到端、**策略说明推导**
(`describe_strategy` 的分支/互斥/缺失值语义)、**股票名称回填**(回测与选股两侧)、 (`describe_strategy` 的分支/互斥/缺失值语义)、**股票名称回填**(回测与选股两侧)、
**UI 对齐与控件一致性**(同排等高 / 尺寸归一 / 点击目标 / 标签 / 无障碍名 / 横向溢出, **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 Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api"; 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 { recentRange } from "@/lib/dates";
import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types"; import type { BacktestCombo, BacktestResult, GlobalConfig, SelectionStrategy } from "@/lib/types";
import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui"; import { PageHeader, Card, Pill, Btn, Banner, Empty, Loading, Field, Progress, BacktestMetrics } from "@/components/ui";
import { BacktestResultView } from "@/components/BacktestResultView"; import { BacktestResultView } from "@/components/BacktestResultView";
import { JobProgress } from "@/components/JobProgress";
import { adjustLabel } from "@/lib/labels"; import { adjustLabel } from "@/lib/labels";
export default function BacktestPage() { 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 { return {
name: d.name.trim(), name: d.name.trim() || nameFallback || "",
description: d.description.trim(), description: d.description.trim(),
strategy_ids: d.strategyIds, strategy_ids: d.strategyIds,
initial_capital: d.initialCapital, initial_capital: d.initialCapital,
@@ -127,11 +137,17 @@ function BacktestInner() {
const [draft, setDraft] = useState<Draft>(emptyDraft(range)); const [draft, setDraft] = useState<Draft>(emptyDraft(range));
const [savedComboId, setSavedComboId] = useState<string | null>(null); const [savedComboId, setSavedComboId] = useState<string | null>(null);
const [result, setResult] = useState<BacktestResult | null>(null); const [result, setResult] = useState<BacktestResult | null>(null);
// 本次结果的归档 id:结果区据此提供「新页面放大」(放大页从归档读同一份数据)。
// 同步/未归档的结果没有 id,放大入口会如实说明原因而不是给个坏链接。
const [resultArchiveId, setResultArchiveId] = useState<string | null>(null);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [runningComboId, setRunningComboId] = useState<string | null>(null); const [runningComboId, setRunningComboId] = useState<string | null>(null);
const [jobId, setJobId] = useState(""); /**
const [stage, setStage] = useState(""); * 作业反馈统一交给 `useJobRunner`(全站同一套):提交瞬间就有「正在提交作业…」,
const [elapsed, setElapsed] = useState(0); * 之后是真实阶段 + 每秒自增的已用时间 + 作业号 + 可取消 —— 用户反馈的
* 「点了回测没有任何反馈,不知道有没有开始」就是这里的缺口。
*/
const job = useJobRunner<BacktestResult>("回测组合");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -222,7 +238,13 @@ function BacktestInner() {
}); });
return; 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); setRunningComboId(comboId);
setError(""); setError("");
setNotice(""); setNotice("");
setJobId("");
setResult(null); setResult(null);
setResultArchiveId(null);
try { try {
const { job_id } = await submit(); const out = await job.run(submit, {
setJobId(job_id); label: draft.name ? `回测组合「${draft.name}」` : "回测组合",
setStage("queued");
const out = await waitJob<BacktestResult>(job_id, 900_000, (info) => {
setStage(info.stage);
setElapsed(info.elapsedMs);
}); });
if (out.status === "success" && out.result) { if (out.status === "success" && out.result) {
setResult(out.result); setResult(out.result as BacktestResult);
const exp = out.experimentId ?? null; const exp = out.experimentId ?? null;
setNotice(exp ? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。` : "回测完成,已自动归档。"); setResultArchiveId(exp);
setNotice(
exp
? `回测完成,已归档为实验 ${exp}(可在「实验对比」页与其它版本对比)。`
: "回测完成,已自动归档。",
);
} else if (out.status === "cancelled") {
// 主动取消不是错误:如实说明,并提示可重新运行
setNotice("已取消该回测任务(未产生结果)。可改完参数后重新运行。");
} else { } else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
} }
@@ -259,7 +285,6 @@ function BacktestInner() {
} finally { } finally {
setRunning(false); setRunning(false);
setRunningComboId(null); setRunningComboId(null);
setJobId("");
} }
} }
@@ -621,23 +646,19 @@ function BacktestInner() {
</div> </div>
</div> </div>
{running ? ( {/* 反馈条紧贴运行按钮:点了就有字,且在排队阶段就能取消。
<div className="stages" style={{ marginTop: 12 }}> kind="backtest":本页跑的是 combo 作业,但后端上报的阶段与 backtest 完全一致
{["data_loading", "backtesting", "analysis"].map((st) => { (combo_service.py:68/70/90),所以共用同一条阶段序列。 */}
const order = ["queued", "data_loading", "backtesting", "analysis", "done"]; <JobProgress
const cur = order.indexOf(stage); state={job.state}
const mine = order.indexOf(st); onCancel={job.cancel}
const cls = mine < cur ? "stage is-done" : mine === cur ? "stage is-active" : "stage"; onDismiss={job.reset}
return ( anchorId="backtest-job-progress"
<span className={cls} key={st}> kind="backtest"
{mine < cur ? "✓" : mine === cur ? "●" : "○"} {STAGE_LABEL[st] ?? st} />
</span> {job.state.phase === "idle" && running ? (
); <div className="hint" style={{ marginTop: 12 }} role="status" aria-live="polite">
})} 正在提交作业…(提交成功后会显示作业号与真实阶段,可随时取消)
<span className="hint">
作业 <span className="mono">{jobId || "排队中…"}</span> · 已用 {Math.round(elapsed / 1000)}s
(全市场多年区间约 3~5 分钟,可离开本页,结果会归档)
</span>
</div> </div>
) : null} ) : null}
</Card> </Card>
@@ -649,7 +670,13 @@ function BacktestInner() {
</Card> </Card>
) : null} ) : 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}
</>
);
}
+428 -85
View File
@@ -11,8 +11,15 @@
* 微调时一眼看出「这次到底改了什么」。 * 微调时一眼看出「这次到底改了什么」。
* *
* 曲线用 TradingView Lightweight Charts(全站统一图表基座)。 * 曲线用 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 Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { apiGet, apiGetWithHeaders, apiPost } from "@/lib/api"; import { apiGet, apiGetWithHeaders, apiPost } from "@/lib/api";
@@ -26,13 +33,14 @@ import {
Btn, Btn,
Banner, Banner,
Empty, Empty,
Metric,
SkeletonLines, SkeletonLines,
Loading, Loading,
} from "@/components/ui"; } from "@/components/ui";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
import { LwChart, type LwSeries } from "@/components/charts/LwChart"; 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`)。 * 类型徽标的**色调**(措辞统一走 `lib/labels.ts::experimentKindLabel`)。
@@ -94,6 +102,40 @@ function shortDataVersion(v: string): string {
return v.length > 28 ? `${v.slice(0, 28)}…` : v; 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" }[] = [ 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: "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" }, { key: "annual_return_pct", label: "年化收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
@@ -124,9 +166,19 @@ function ExperimentsInner() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<ExperimentDetail | null>(null); const [detail, setDetail] = useState<ExperimentDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false); 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 [jobId, setJobId] = useState("");
const [jobStatus, setJobStatus] = useState(""); const [jobStatus, setJobStatus] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
/**
* 操作结果提示(删除回执等):与 error 分开 —— 红色横幅只留给真正的失败。
* tone 显式存下来,而不是在渲染时用文案里有没有"不存在"去猜(文案一改就会猜错)。
*/
const [notice, setNotice] = useState<{ text: string; tone: "info" | "warn" } | null>(null);
// 对比状态 // 对比状态
const [picked, setPicked] = useState<string[]>([]); const [picked, setPicked] = useState<string[]>([]);
@@ -134,6 +186,16 @@ function ExperimentsInner() {
const [comparing, setComparing] = useState(false); const [comparing, setComparing] = useState(false);
const [onlyDiff, setOnlyDiff] = useState(true); 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 响应头)。 // 列表过滤:后端支持 kind/q(并把过滤后总数放在 X-Total-Count 响应头)。
// 初始值取自 URL(`?q=`/`?kind=`),且每次改动**回写 URL**:筛选条件可分享、刷新不丢, // 初始值取自 URL(`?q=`/`?kind=`),且每次改动**回写 URL**:筛选条件可分享、刷新不丢,
// 也让「当前看到的是哪一批」有据可查(而不是只存在于组件状态里)。 // 也让「当前看到的是哪一批」有据可查(而不是只存在于组件状态里)。
@@ -186,13 +248,113 @@ function ExperimentsInner() {
// eslint-disable-next-line react-hooks/exhaustive-deps // 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); setDetail(null);
apiGet<ExperimentDetail>(`/experiments/${id}`) setDetailError("");
.then((d) => setDetail(d)) setDetailLoading(true);
.catch((e: Error) => setError(e.message)) apiGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`)
.finally(() => setDetailLoading(false)); .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) { function rerun(exp: ExperimentMeta) {
@@ -218,7 +380,8 @@ function ExperimentsInner() {
if (j.status === "success" || j.status === "failed" || tries > 60) { if (j.status === "success" || j.status === "failed" || tries > 60) {
window.clearInterval(timer); window.clearInterval(timer);
load(); load();
setDetail(null); // 复跑会新增归档;图层里那份可能是刚被替换的旧快照,关掉避免看串
closeDetail();
} }
}) })
.catch(() => window.clearInterval(timer)); .catch(() => window.clearInterval(timer));
@@ -258,7 +421,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 ( return (
<> <>
@@ -276,10 +479,45 @@ function ExperimentsInner() {
{error ? <Banner tone="error">{error}</Banner> : null} {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"> <div className="sticky-bar">
{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"> <Pill tone="violet" icon="layers">
已选 {picked.length} 个 已选 {picked.length} 个(对比)
</Pill> </Pill>
<span className="hint">{picked.join(" · ")}</span> <span className="hint">{picked.join(" · ")}</span>
<Btn variant="primary" icon="chartLine" loading={comparing} disabled={comparing || picked.length < 2} onClick={runCompare}> <Btn variant="primary" icon="chartLine" loading={comparing} disabled={comparing || picked.length < 2} onClick={runCompare}>
@@ -289,6 +527,8 @@ function ExperimentsInner() {
清空 清空
</Btn> </Btn>
{picked.length < 2 ? <span className="hint">再选 1 个即可对比</span> : null} {picked.length < 2 ? <span className="hint">再选 1 个即可对比</span> : null}
</>
) : null}
</div> </div>
) : null} ) : null}
@@ -315,7 +555,7 @@ function ExperimentsInner() {
? `显示 ${exps.length} 条 / 共 ${total} 条${ ? `显示 ${exps.length} 条 / 共 ${total} 条${
total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : "" total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : ""
}` }`
: "勾选左侧方框可选 2~3 个做对比" : "第一列勾选可批量删除;「对比」列勾选 2~3 个可叠加曲线对比"
} }
tools={ tools={
<div className="row" style={{ gap: 8 }}> <div className="row" style={{ gap: 8 }}>
@@ -376,9 +616,44 @@ function ExperimentsInner() {
<table className="tbl"> <table className="tbl">
<thead> <thead>
<tr> <tr>
<th style={{ width: 36 }} aria-label="选择对比" /> {/* 第一列:批量删除的选择(表头是全选本页)。与「对比」列分开,
因为两者用途/上限完全不同(删除可多选且不可逆,对比最多 3 个)。 */}
<th
style={{ width: 36 }}
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 style={{ width: 36 }}>
<span className="hint" title="勾选这一列可挑 2~3 个实验做对比">
对比
</span>
</th>
<th>ID</th> <th>ID</th>
<th>类型</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>摘要</th>
@@ -388,11 +663,21 @@ function ExperimentsInner() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{exps.map((e) => ( {rows.map((e) => (
<tr key={e.id} className={detail?.id === e.id ? "row-active" : undefined}> <tr key={e.id} className={detailId === e.id ? "row-active" : undefined}>
<td> <td>
{/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */} {/* 整个单元都是热区: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 <input
type="checkbox" type="checkbox"
checked={picked.includes(e.id)} checked={picked.includes(e.id)}
@@ -403,6 +688,9 @@ function ExperimentsInner() {
</td> </td>
<td className="cell-mono cell-strong">{e.id}</td> <td className="cell-mono cell-strong">{e.id}</td>
<td>{kindView(e.kind)}</td> <td>{kindView(e.kind)}</td>
<td className="cell-mono nowrap" title={e.created_at ?? "该归档没有记录发起时间"}>
{fmtLocalTime(e.created_at)}
</td>
<td> <td>
<span className="row" style={{ gap: 4 }}> <span className="row" style={{ gap: 4 }}>
{e.factors.map((f) => ( {e.factors.map((f) => (
@@ -435,10 +723,22 @@ function ExperimentsInner() {
</td> </td>
<td style={{ textAlign: "right" }}> <td style={{ textAlign: "right" }}>
<span className="row" style={{ justifyContent: "flex-end", gap: 6 }}> <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> <span>打开归档</span>
</Link> </a>
<Btn size="sm" icon="search" onClick={() => open(e.id)}> <Btn
size="sm"
icon="search"
// 把触发按钮传进图层:关闭后焦点要回到它(键盘用户不"掉回页首")
onClick={(ev) => openDetail(e.id, ev.currentTarget)}
>
详情 详情
</Btn> </Btn>
<Link href={`/backtest?from_experiment=${encodeURIComponent(e.id)}`} className="btn btn--sm"> <Link href={`/backtest?from_experiment=${encodeURIComponent(e.id)}`} className="btn btn--sm">
@@ -463,83 +763,126 @@ function ExperimentsInner() {
)} )}
</Card> </Card>
{detail ? ( {/* 详情图层:原地看归档,不再把用户从列表带走(列表的筛选/选择/滚动位置都留着)。
<Card 「选股条件 / 交易执行依据」那部分说明由后端 describe_strategy 依归档 spec 推导
icon="book" (归档详情页是 Server Component,在服务端调用它)—— 图层里不重复这份推导,
title={`${detail.id} · ${detail.factors.join("、")}`} 也不自行拼造文案;要看它就点标题栏的「在新页面打开完整归档」。 */}
sub={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : undefined} {detailId ? (
<Modal
title={
<>
{detailId} 归档详情
{detail ? ` · ${experimentKindLabel(detail.kind)}` : ""}
</>
}
onClose={closeDetail}
returnFocusTo={detailTrigger}
tools={ tools={
<div className="row" style={{ gap: 8 }}> <a
<Link href={`/experiments/${encodeURIComponent(detail.id)}`} className="btn btn--sm"> className="btn btn--sm"
<span>打开归档(完整视图)</span> href={`/experiments/${encodeURIComponent(detailId)}`}
</Link> target="_blank"
<Btn size="sm" onClick={() => setDetail(null)}> rel="noreferrer"
<Icon name="x" size={13} /> >
关闭 <span>在新页面打开完整归档</span>
</Btn> </a>
</div>
} }
> >
{detailLoading ? ( {detailError ? (
<SkeletonLines n={3} /> /* 后端错误原文照贴:404(已被删)和 500(后端异常)该做的事完全不同,
) : ( 只写「加载失败」等于把可排查的信息丢掉。 */
<Banner tone="error">详情加载失败:{detailError}</Banner>
) : detailLoading ? (
<> <>
{s ? ( <div className="hint" style={{ marginBottom: 8 }}>
<div className="metric-grid" style={{ marginBottom: 0 }}> 正在读取归档 {detailId}…
<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>
<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,
}}
/>
) : ( ) : (
<div className="hint">该实验没有可展示的汇总结果(可能是因子测试或历史版本)。</div> <Empty
)} icon="archive"
{detail && isBacktestDetail(detail) && detail.result.equity_curve.length ? ( title="该归档没有可展示的结果"
<div style={{ marginTop: 12 }}> hint="结果为空(可能是历史版本归档)。可用标题栏的「在新页面打开完整归档」,在那边「导出完整 JSON」查看原始内容。"
<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}
</>
)} )}
</Card> </>
) : (
<div className="hint">详情为空 —— 这条归档可能刚被删除,请刷新列表核对。</div>
)}
</Modal>
) : null} ) : 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>
);
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */ /** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */
+50 -88
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { apiGet } from "@/lib/api"; import { apiGet } from "@/lib/api";
import { submitJob, waitJob } from "@/lib/jobs"; import { submitJob, useJobRunner } from "@/lib/jobs";
import { factorLabel, pickableFactors } from "@/lib/factors"; import { factorLabel, pickableFactors } from "@/lib/factors";
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types"; import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
import { recentRange } from "@/lib/dates"; import { recentRange } from "@/lib/dates";
@@ -13,14 +13,10 @@ import {
Field, Field,
Btn, Btn,
Banner, Banner,
Progress,
Signed, Signed,
BacktestMetrics,
MonthlyReturnsTable,
UnimplementedNote,
} from "@/components/ui"; } from "@/components/ui";
import { LwChart, type LwSeries } from "@/components/charts/LwChart"; import { BacktestResultView } from "@/components/BacktestResultView";
import { CHART, fmtNum, fmtPct } from "@/components/charts/theme"; import { JobProgress } from "@/components/JobProgress";
import { Icon } from "@/components/icons"; import { Icon } from "@/components/icons";
interface Pick { interface Pick {
@@ -37,8 +33,18 @@ export default function ComposePage() {
const [start, setStart] = useState(""); const [start, setStart] = useState("");
const [end, setEnd] = useState(""); const [end, setEnd] = useState("");
const [result, setResult] = useState<BacktestResult | null>(null); const [result, setResult] = useState<BacktestResult | null>(null);
const [running, setRunning] = useState(false); /** 本次结果的归档 id:结果区的「新页面放大」从归档读同一份数据 */
const [jobId, setJobId] = useState(""); 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(""); const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
@@ -84,11 +90,9 @@ export default function ComposePage() {
setError("请选择至少一个因子并设置大于 0 的权重"); setError("请选择至少一个因子并设置大于 0 的权重");
return; return;
} }
setRunning(true);
setError(""); setError("");
setJobId("");
setResult(null); setResult(null);
try { setArchiveId(null); // 新一次运行:先清掉上一次的归档 id,避免放大到旧结果
const spec: ResearchSpec = { const spec: ResearchSpec = {
type: "backtest", type: "backtest",
universe: { exclude_st: excludeSt, min_listing_days: 0 }, universe: { exclude_st: excludeSt, min_listing_days: 0 },
@@ -97,20 +101,16 @@ export default function ComposePage() {
rebalance, rebalance,
period: [start, end], period: [start, end],
}; };
const { job_id } = await submitJob(spec); const out = await job.run(() => submitJob(spec), {
setJobId(job_id); label: `因子组合回测(${valid.length} 个因子)`,
const out = await waitJob<BacktestResult>(job_id); });
if (out.status === "success" && out.result) { if (out.status === "success" && out.result) {
setResult(out.result); setResult(out.result);
} else { // 归档 id 交给结果区做「新页面放大」;反馈条自己也会给「打开归档 / 去对比」
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`); setArchiveId(out.experimentId ?? null);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
setJobId("");
} }
// 失败 / 超时 / 取消都只在反馈条上说:后端原文(out.error)、
// 「已取消(不产生归档)」都在 JobProgress 里,页面不再另写一份 Banner 重复同一个失败
} }
return ( return (
@@ -266,30 +266,32 @@ export default function ComposePage() {
<Btn <Btn
variant="primary" variant="primary"
icon="play" icon="play"
loading={running} loading={active}
disabled={running || picks.length === 0} disabled={active || picks.length === 0}
onClick={run} onClick={run}
> >
{running ? "后台运行中…" : `回测组合(${picks.length} 个因子)`} {active ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
</Btn> </Btn>
</div> </div>
{running ? ( {/* 反馈条紧贴运行按钮:提交瞬间就有字,排队阶段就能取消,完成后给归档入口 */}
<div style={{ marginTop: 14 }}> <JobProgress
<Progress state={job.state}
value={30} onCancel={job.cancel}
label={ onDismiss={job.reset}
<> anchorId="factors-compose-job-progress"
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,完成后结果自动展示在本页。 kind="backtest"
</>
}
/> />
</div>
) : null}
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null} {error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card> </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({ function ResultView({
result, result,
params, params,
archiveId,
}: { }: {
result: BacktestResult; result: BacktestResult;
params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string }; params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string };
archiveId?: string | null;
}) { }) {
const s = result.summary; 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 ( return (
<> <>
<div className="between" style={{ margin: "6px 0 14px" }}> <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 tone="pos" icon="check">
回测完成 回测完成
</Pill> </Pill>
@@ -334,41 +317,20 @@ function ResultView({
<Pill>{params.rebalance === "monthly" ? "月度调仓" : "周度调仓"}</Pill> <Pill>{params.rebalance === "monthly" ? "月度调仓" : "周度调仓"}</Pill>
{params.excludeSt ? <Pill>剔除 ST</Pill> : null} {params.excludeSt ? <Pill>剔除 ST</Pill> : null}
<Pill>{params.start} ~ {params.end}</Pill> <Pill>{params.start} ~ {params.end}</Pill>
<Pill>{params.picks.length} 个因子</Pill>
</div> </div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}> <div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
区间收益 <Signed value={s.total_return_pct} suffix="%" /> 区间收益 <Signed value={s.total_return_pct} suffix="%" />
</div> </div>
</div> </div>
<BacktestMetrics s={s} /> {/* 与「回测组合」共用同一个结果视图:买卖理由、因子曲线、放大入口都在那里,
两处各写一份迟早出现「同一个结果两套图」的口径漂移 */}
<div className="chart-grid"> <BacktestResultView
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}> result={result}
<LwChart name="因子组合"
series={equitySeries} archive={archiveId ? { id: archiveId } : undefined}
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} />
</> </>
); );
} }
+38 -43
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { apiGet, apiPatch, apiPost } from "@/lib/api"; import { apiGet, apiPatch, apiPost } from "@/lib/api";
import { submitJob, waitJob } from "@/lib/jobs"; import { submitJob, useJobRunner } from "@/lib/jobs";
import type { import type {
FactorMeta, FactorMeta,
FactorParam, FactorParam,
@@ -19,10 +19,10 @@ import {
Field, Field,
Btn, Btn,
Banner, Banner,
Progress,
Empty, Empty,
SkeletonLines, SkeletonLines,
} from "@/components/ui"; } from "@/components/ui";
import { JobProgress } from "@/components/JobProgress";
/** /**
* 因子研究页。 * 因子研究页。
@@ -180,10 +180,15 @@ export default function FactorsPage() {
const [start, setStart] = useState(""); const [start, setStart] = useState("");
const [end, setEnd] = useState(""); const [end, setEnd] = useState("");
const [reports, setReports] = useState<Record<string, FactorTestReport>>({}); const [reports, setReports] = useState<Record<string, FactorTestReport>>({});
const [running, setRunning] = useState(false); /**
const [processed, setProcessed] = useState(0); * 作业反馈统一交给 `useJobRunner`:本页是「一个因子一个 Job」的批量场景,
const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null); * 每轮把 (第 i/共 n 个) 作为 progress 交给反馈条 —— 阶段、作业号、已用秒数全部来自后端,
const [jobId, setJobId] = useState(""); * 不再由前端按 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 [error, setError] = useState("");
/** 目录级成功提示(停用 / 启用)。 */ /** 目录级成功提示(停用 / 启用)。 */
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
@@ -319,16 +324,13 @@ export default function FactorsPage() {
setError("请至少选择一个因子"); setError("请至少选择一个因子");
return; return;
} }
setRunning(true);
setError(""); setError("");
setJobId("");
setReports({}); setReports({});
setProcessed(0); // 多因子是**一个因子一个 Job**(后端逐因子归档),所以这里顺序提交、逐个 await;
try { // 每轮都把进度交给统一反馈条:点了第一个就有「正在提交作业…」+ 作业号,不必等全部跑完。
let index = 0; let index = 0;
for (const name of names) { for (const name of names) {
index += 1; index += 1;
setCurrent({ idx: index, total: names.length, name });
const spec: ResearchSpec = { const spec: ResearchSpec = {
type: "factor_test", type: "factor_test",
universe: { exclude_st: true, min_listing_days: 0 }, universe: { exclude_st: true, min_listing_days: 0 },
@@ -337,29 +339,26 @@ export default function FactorsPage() {
rebalance: "monthly", rebalance: "monthly",
period: [start, end], period: [start, end],
}; };
const { job_id } = await submitJob(spec); const out = await job.run(() => submitJob(spec), {
setJobId(job_id); label: `单因子测试 · ${name}`,
const out = await waitJob<FactorTestReport>(job_id); progress: { index, total: names.length },
});
if (out.status === "success" && out.result) { if (out.status === "success" && out.result) {
setReports((prev) => ({ ...prev, [name]: out.result! })); setReports((prev) => ({ ...prev, [name]: out.result! }));
} else if (out.status === "cancelled") {
// 取消是用户的明确意图,不是错误:已经跑完的报告留在页面上,后面的因子不再跑
break;
} else { } else {
// 单个因子失败不该吞掉其它因子的报告:把后端原文累积成一份批级错误清单,
// 逐个跑(而不是整批放弃)也和老行为一致
const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`; const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`;
setError((prev) => (prev ? `${prev}\n${msg}` : msg)); setError((prev) => (prev ? `${prev}\n${msg}` : msg));
} }
setProcessed(index);
}
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
setCurrent(null);
setJobId("");
} }
} }
const selectedCount = checked.size; const selectedCount = checked.size;
const reportNames = Object.keys(reports); 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 tpl = templates.find((x) => x.name === tplName) ?? null;
const tplSpecs = tpl?.param_specs ?? []; const tplSpecs = tpl?.param_specs ?? [];
@@ -372,12 +371,12 @@ export default function FactorsPage() {
actions={<Pill tone="accent" icon="flask">{reportNames.length}/{selectedCount} 个已出报告</Pill>} actions={<Pill tone="accent" icon="flask">{reportNames.length}/{selectedCount} 个已出报告</Pill>}
/> />
{error && !running ? ( {error && !active ? (
<Banner tone="error"> <Banner tone="error">
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre> <pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
</Banner> </Banner>
) : null} ) : null}
{notice && !running ? <Banner tone="info">{notice}</Banner> : null} {notice && !active ? <Banner tone="info">{notice}</Banner> : null}
<Card <Card
title="因子目录" title="因子目录"
@@ -579,7 +578,7 @@ export default function FactorsPage() {
)} )}
</Card> </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"> <div className="form-grid">
<Field label="开始日期"> <Field label="开始日期">
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} /> <input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
@@ -590,34 +589,30 @@ export default function FactorsPage() {
<Btn <Btn
variant="primary" variant="primary"
icon="play" icon="play"
loading={running} loading={active}
disabled={running || selectedCount === 0} disabled={active || selectedCount === 0}
onClick={run} onClick={run}
> >
{running {/* 按钮上只保留「跑第几个」这一条信息;秒表 / 阶段 / 作业号都在反馈条上,不重复 */}
? `运行中 ${processed}/${selectedCount}` {active
? `运行中 ${job.state.progress?.index ?? 0}/${job.state.progress?.total ?? selectedCount}`
: selectedCount === 0 : selectedCount === 0
? "请先勾选因子" ? "请先勾选因子"
: `运行因子测试(${selectedCount} 个)`} : `运行因子测试(${selectedCount} 个)`}
</Btn> </Btn>
</div> </div>
{running ? ( {/* 反馈条:点了立刻有字(正在提交作业…),随后是真实阶段 + 每秒自增的已用时间 + 可取消 */}
<div style={{ marginTop: 14 }}> <JobProgress
<Progress state={job.state}
value={progressPct} onCancel={job.cancel}
label={ onDismiss={job.reset}
<> anchorId="factors-job-progress"
正在运行:<b>{current?.name}</b>({current?.idx}/{current?.total})· 后台任务{" "} kind="factor_test"
<span className="mono">{jobId || "排队中…"}</span>
</>
}
/> />
</div>
) : null}
</Card> </Card>
{reportNames.length === 0 && !running && !error ? ( {reportNames.length === 0 && !active && !error ? (
<Card> <Card>
<Empty <Empty
icon="chartLine" icon="chartLine"
+124
View File
@@ -2185,3 +2185,127 @@ button.chip:hover {
min-width: 0; min-width: 0;
flex: 1 1 320px; 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;
}
.th-sort {
appearance: none;
border: 0;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0;
}
.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;
}
+38 -17
View File
@@ -8,7 +8,7 @@ import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api"; import { apiGet, apiPost } from "@/lib/api";
import { SymbolLink } from "@/lib/symbols"; import { SymbolLink } from "@/lib/symbols";
import { waitJob } from "@/lib/jobs"; import { useJobRunner } from "@/lib/jobs";
import { factorOptionLabel, pickableFactors } from "@/lib/factors"; import { factorOptionLabel, pickableFactors } from "@/lib/factors";
import type { import type {
FactorMeta, FactorMeta,
@@ -28,6 +28,7 @@ import {
Empty, Empty,
SkeletonLines, SkeletonLines,
} from "@/components/ui"; } from "@/components/ui";
import { JobProgress } from "@/components/JobProgress";
const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref" }[] = [ const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref" }[] = [
{ value: "static.industry", label: "行业 industry", kind: "str" }, { 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 → 坏链接。 * 拿它拼 /backtest?from_selection=<JOB id> 会让接收端 GET /api/selections/{id} 404 → 坏链接。
*/ */
const [savedSelectionId, setSavedSelectionId] = useState(""); const [savedSelectionId, setSavedSelectionId] = useState("");
/**
* 同步选股(POST /selections)正在执行 —— 这条路径是**同步接口**,没有 job_id,
* 所以不能套 useJobRunner(它要轮询 /jobs/{id})。保留 running 只用于这条路径的按钮态。
*/
const [running, setRunning] = useState(false); 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 [error, setError] = useState("");
const [history, setHistory] = useState<SelectionMeta[] | null>(null); const [history, setHistory] = useState<SelectionMeta[] | null>(null);
@@ -146,26 +161,24 @@ export default function SelectionPage() {
/** 异步(全市场等长任务):提交 Job 后台执行并轮询(解决同步 60s+)。 */ /** 异步(全市场等长任务):提交 Job 后台执行并轮询(解决同步 60s+)。 */
async function runAsync() { async function runAsync() {
setAsyncBusy(true);
setError(""); setError("");
setResult(null); setResult(null);
// 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接) // 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接)
setSavedSelectionId(""); setSavedSelectionId("");
try { // 提交闭包把真实 job_id 记下来:页头那个 Pill 沿用老行为显示本次运行的 id,
const { job_id } = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery()); // 而 job.state.jobId 在 await 之后读到的仍是本轮渲染的旧值(React 状态不会在同一 tick 内更新)
const out = await waitJob<SelectionResult>(job_id); 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) { if (out.status === "success" && out.result) {
setSelectionId(job_id); setSelectionId(submittedId);
setResult(out.result); setResult(out.result);
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined); 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);
} }
// 失败 / 超时 / 取消:反馈条已给出后端原文或「已取消(不产生归档)」,不在这里重复报错
} }
async function openHistory(id: string) { async function openHistory(id: string) {
@@ -378,13 +391,21 @@ export default function SelectionPage() {
)} )}
<div style={{ marginTop: 14 }}> <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 ? "筛选中…" : "执行选股"} {running ? "筛选中…" : "执行选股"}
</Btn> </Btn>
<Btn variant="ghost" icon="clock" loading={asyncBusy} disabled={running || asyncBusy} onClick={runAsync}> <Btn variant="ghost" icon="clock" loading={asyncActive} disabled={running || asyncActive} onClick={runAsync}>
{asyncBusy ? "后台执行中…" : "异步(全市场)"} {asyncActive ? "后台执行中…" : "异步(全市场)"}
</Btn> </Btn>
</div> </div>
{/* 异步作业的反馈条:点了立刻有字 + 真实阶段 + 可取消(同步按钮不走这里) */}
<JobProgress
state={job.state}
onCancel={job.cancel}
onDismiss={job.reset}
anchorId="selection-job-progress"
kind="selection"
/>
{error ? ( {error ? (
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<Banner tone="error">{error}</Banner> <Banner tone="error">{error}</Banner>
+15
View File
@@ -138,6 +138,21 @@ export default function SignalsPage() {
{running ? "生成中…" : "生成信号"} {running ? "生成中…" : "生成信号"}
</Btn> </Btn>
</div> </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} {error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card> </Card>
+128 -65
View File
@@ -29,6 +29,20 @@ import Link from "next/link";
import { SymbolLink, useSymbolNames } from "@/lib/symbols"; import { SymbolLink, useSymbolNames } from "@/lib/symbols";
import { adjustLabel } from "@/lib/labels"; import { adjustLabel } from "@/lib/labels";
import type { ActionRecord, BacktestResult, SymbolCurve } from "@/lib/types"; 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 { export interface ArchiveInfo {
id: string; id: string;
@@ -70,37 +84,19 @@ export function BacktestResultView({
const nameOf = (c: SymbolCurve) => c.name ?? nameCache[c.symbol] ?? ""; const nameOf = (c: SymbolCurve) => c.name ?? nameCache[c.symbol] ?? "";
const topRef = useRef<HTMLDivElement | null>(null); const topRef = useRef<HTMLDivElement | null>(null);
// 组合净值上的买卖点:同一日的成交合并成一个标记,落在当日净值上 // 曲线序列统一走 lib/chartSeries(与「新页面放大」共用同一套口径与格式)
const equitySeries = useMemo<LwSeries[]>( const equityData = useMemo<LwSeries[]>(() => equitySeries(result), [result]);
() => [ const equityMarkers = useMemo<LwMarker[]>(
{ () => portfolioMarkers(result.fills, new Set(result.equity_curve.map((p) => p.date))),
key: "equity", [result]
label: "组合净值(元)",
type: "area",
color: CHART.pos,
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
],
[result.equity_curve]
); );
const equityMarkers = useMemo<LwMarker[]>(() => { // 因子键 → 展示名:买卖理由里的因子值要用中文名,不能只甩引擎键
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>(); const factorLabels = useMemo<FactorLabels>(() => {
for (const f of result.fills ?? []) { const out: FactorLabels = {};
const cur = byDate.get(f.date) ?? { BUY: false, SELL: false }; for (const f of result.factor_curves ?? []) out[f.name] = f.label;
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: "卖" });
}
return out; return out;
}, [result.fills, result.equity_curve]); }, [result.factor_curves]);
const filteredCurves = useMemo(() => { const filteredCurves = useMemo(() => {
const q = curveQuery.trim().toLowerCase(); const q = curveQuery.trim().toLowerCase();
@@ -119,10 +115,12 @@ export function BacktestResultView({
const SECTIONS = [ const SECTIONS = [
{ id: "sec-equity", label: "整体收益" }, { id: "sec-equity", label: "整体收益" },
{ id: "sec-factors", label: "因子曲线" },
{ id: "sec-symbols", label: "个股曲线" }, { id: "sec-symbols", label: "个股曲线" },
{ id: "sec-monthly", label: "月度/年度" }, { id: "sec-monthly", label: "月度/年度" },
{ id: "sec-holdings", label: "持仓" }, { id: "sec-holdings", label: "持仓" },
{ id: "sec-trades", label: "成交明细" }, { id: "sec-trades", label: "成交明细" },
{ id: "sec-reasons", label: "买卖说明" },
]; ];
return ( return (
@@ -175,16 +173,19 @@ export function BacktestResultView({
icon="chartLine" icon="chartLine"
title="整体收益趋势(含买卖点)" title="整体收益趋势(含买卖点)"
tools={ tools={
<div className="row" style={{ gap: 6 }}>
<Pill tone="pos"> <Pill tone="pos">
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日 期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
</Pill> </Pill>
<ChartPopoutLink archiveId={archive?.id} series="equity" />
</div>
} }
> >
<LwChart <LwChart
series={equitySeries} series={equityData}
markers={equityMarkers} markers={equityMarkers}
height={320} height={320}
valueFormat={(v) => fmtNum(v)} valueFormat={equityValueFormat}
ariaLabel="组合净值曲线与买卖点" ariaLabel="组合净值曲线与买卖点"
/> />
<div className="hint" style={{ marginTop: 6 }}> <div className="hint" style={{ marginTop: 6 }}>
@@ -195,19 +196,15 @@ export function BacktestResultView({
<Card <Card
icon="chartLine" icon="chartLine"
title="回撤(%)" 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 <LwChart
series={[ series={drawdownSeries(result)}
{
key: "dd",
label: "回撤(%)",
type: "area",
color: CHART.neg,
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
]}
height={320} height={320}
valueFormat={(v) => `${v.toFixed(2)}%`} valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine zeroLine
@@ -261,6 +258,11 @@ export function BacktestResultView({
曲线口径:该股被持有期间按日复利累计(建仓当日为 0%);未持有期间不绘制, 曲线口径:该股被持有期间按日复利累计(建仓当日为 0%);未持有期间不绘制,
分段间以直线连接,请以买卖点区分持仓区间。 分段间以直线连接,请以买卖点区分持仓区间。
</span> </span>
<ChartPopoutLink
archiveId={archive?.id}
series={`sym:${activeCurve?.symbol ?? ""}`}
label="放大当前个股"
/>
</div> </div>
{activeCurve ? <SymbolCurveChart curve={activeCurve} /> : null} {activeCurve ? <SymbolCurveChart curve={activeCurve} /> : null}
<div className="table-wrap" style={{ marginTop: 12 }}> <div className="table-wrap" style={{ marginTop: 12 }}>
@@ -303,7 +305,14 @@ export function BacktestResultView({
)} )}
</Card> </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 <LwChart
series={[ series={[
{ {
@@ -408,6 +417,8 @@ export function BacktestResultView({
<th>买价</th> <th>买价</th>
<th>卖价</th> <th>卖价</th>
<th>收益</th> <th>收益</th>
<th>为什么买</th>
<th>为什么卖</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -423,6 +434,12 @@ export function BacktestResultView({
<td className={t.return_pct >= 0 ? "tone-pos" : "tone-neg"}> <td className={t.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{t.return_pct.toFixed(2)}% {t.return_pct.toFixed(2)}%
</td> </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> </tr>
))} ))}
</tbody> </tbody>
@@ -433,6 +450,8 @@ export function BacktestResultView({
<NotFilledCard signals={result.signal_history ?? []} /> <NotFilledCard signals={result.signal_history ?? []} />
<TradeReasonsCard signals={result.signal_history ?? []} factorLabels={factorLabels} />
<UnimplementedNote items={result.unimplemented} /> <UnimplementedNote items={result.unimplemented} />
</> </>
); );
@@ -465,31 +484,10 @@ function NotFilledCard({ signals }: { signals: ActionRecord[] }) {
} }
function SymbolCurveChart({ curve }: { curve: SymbolCurve }) { 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 ( return (
<LwChart <LwChart
series={[ series={symbolSeries(curve)}
{ markers={symbolMarkers(curve)}
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}
height={300} height={300}
valueFormat={(v) => `${v.toFixed(2)}%`} valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine 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 { function poolLabel(result: BacktestResult): string {
const sel = result.config_snapshot?.selection as const sel = result.config_snapshot?.selection as
| { top_n?: number; hold_top_x?: number | null } | { 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[]; series: LwSeries[];
markers?: LwMarker[]; markers?: LwMarker[];
height?: number; height?: number;
/** 悬浮提示与价格轴的数值格式 */ /** 悬浮提示与价格轴的数值格式(客户端组件用;Server Component 请用 formatKey) */
valueFormat?: (v: number) => string; valueFormat?: (v: number) => string;
/**
* 数值格式的**可序列化标识**。
*
* 为什么需要它:Server Component 不能把函数传给 Client Component(`valueFormat`
* 会直接 500:「Functions cannot be passed directly to Client Components」)。
* 因此服务端渲染的图表(归档详情、曲线放大页)用这个标识指定格式,由本组件在
* 客户端解析成函数 —— 两端看到的数字格式仍然只有一份定义。
*/
formatKey?: LwFormatKey;
/** 画一条 0 基准虚线(收益率曲线推荐开启) */ /** 画一条 0 基准虚线(收益率曲线推荐开启) */
zeroLine?: boolean; zeroLine?: boolean;
legend?: boolean; legend?: boolean;
@@ -73,6 +82,27 @@ export interface LwChartProps {
type AnySeries = ISeriesApi<"Line"> | ISeriesApi<"Area"> | ISeriesApi<"Histogram">; 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 { function toTime(t: string): Time {
return t as Time; return t as Time;
} }
@@ -102,6 +132,7 @@ export function LwChart({
markers = [], markers = [],
height = 320, height = 320,
valueFormat, valueFormat,
formatKey,
zeroLine = false, zeroLine = false,
legend = true, legend = true,
ariaLabel, ariaLabel,
@@ -114,8 +145,9 @@ export function LwChart({
const zeroLineDrawn = useRef(false); const zeroLineDrawn = useRef(false);
/** 图例隐藏集合:tooltip 订阅里读取,用 ref 避免闭包过期 */ /** 图例隐藏集合:tooltip 订阅里读取,用 ref 避免闭包过期 */
const hiddenRef = useRef<Set<string>>(new Set()); const hiddenRef = useRef<Set<string>>(new Set());
const fmtRef = useRef(valueFormat); const resolvedFormat = valueFormat ?? (formatKey ? FORMATTERS[formatKey] : undefined);
fmtRef.current = valueFormat; const fmtRef = useRef(resolvedFormat);
fmtRef.current = resolvedFormat;
const [hidden, setHidden] = useState<Set<string>>(new Set()); const [hidden, setHidden] = useState<Set<string>>(new Set());
const [tip, setTip] = useState<{ const [tip, setTip] = useState<{
+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 后台执行, * 大样本研究(全市场)可能耗时数十秒到分钟级,经异步 Job 后台执行,
* 避免 HTTP 长阻塞(AGENT §19)。页面提交后即时返回 job_id,再轮询到终态。 * 避免 HTTP 长阻塞(AGENT §19)。页面提交后即时返回 job_id,再轮询到终态。
*/ */
import { useCallback, useEffect, useRef, useState } from "react";
import { apiGet, apiPost } from "./api"; import { apiGet, apiPost } from "./api";
import type { ResearchSpec } from "./types"; import type { ResearchSpec } from "./types";
@@ -65,16 +67,65 @@ export async function waitJob<T>(
return { status: "timeout", result: null, error: "等待结果超时,请稍后在「实验」页查看归档" }; return { status: "timeout", result: null, error: "等待结果超时,请稍后在「实验」页查看归档" };
} }
/** 阶段中文名(后端 stage 枚举 → 用户可读) */ /** 阶段中文名(后端 stage 枚举 → 用户可读)。
*
* 必须覆盖每种作业**上报过的全部阶段**:漏一个,JobProgress 只能把裸枚举摆给用户
* —— 实测漏过 `factor_calculation`(/factors 反馈条上直接出现过英文 `factor_calculation`)。
* 新增阶段时请同时补这里的中文名与下方 `STAGE_PIPELINES` 里对应的序列。
*/
export const STAGE_LABEL: Record<string, string> = { export const STAGE_LABEL: Record<string, string> = {
queued: "排队中", queued: "排队中",
data_loading: "加载行情与因子数据", data_loading: "加载行情与因子数据",
factor_calculation: "计算因子值",
selection: "逐择股日选股", selection: "逐择股日选股",
backtesting: "撮合与净值结算", backtesting: "撮合与净值结算",
analysis: "汇总指标与曲线", analysis: "汇总指标与曲线",
done: "完成", 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,不保存组合)。 */ /** 提交一个回测组合为异步 Job(POST /api/combos/run,不保存组合)。 */
export async function submitComboJob(combo: unknown): Promise<JobSubmit> { 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> { export async function runSavedCombo(comboId: string): Promise<JobSubmit> {
return apiPost<JobSubmit>(`/combos/${encodeURIComponent(comboId)}/run`, {}); 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 为空字符串表示组合级提示。 */ /** 交易意图与成交记录(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 { export interface ActionRecord {
date: string; date: string;
symbol: string; symbol: string;
@@ -200,6 +254,17 @@ export interface ActionRecord {
filled: boolean; filled: boolean;
reject_reason?: string | null; reject_reason?: string | null;
price?: number | 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 }[]; monthly_returns: { year: number; month: number; return_pct: number }[];
yearly_returns: { year: number; return_pct: number }[]; yearly_returns: { year: number; return_pct: number }[];
positions: { date: string; symbol: string; name?: string | null; weight: 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 }[]; selection_history?: { date: string; symbol: string; name?: string | null; rank: number; score: number }[];
signal_history?: ActionRecord[]; signal_history?: ActionRecord[];
fills?: ActionRecord[]; fills?: ActionRecord[];
symbol_curves?: SymbolCurve[]; symbol_curves?: SymbolCurve[];
/** 策略用到的每个因子的时间序列(持仓加权平均原始值):解释买卖依据 */
factor_curves?: FactorCurve[];
turnover_pct: number; turnover_pct: number;
unimplemented: string[]; unimplemented: string[];
config_snapshot: Record<string, unknown>; config_snapshot: Record<string, unknown>;
+54 -1
View File
@@ -93,7 +93,7 @@ def main() -> int:
required = [ required = [
"summary", "equity_curve", "drawdown", "monthly_returns", "yearly_returns", "summary", "equity_curve", "drawdown", "monthly_returns", "yearly_returns",
"positions", "trades", "selection_history", "signal_history", "fills", "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] missing = [k for k in required if k not in res]
assert not missing, f"结果缺少页面读取的字段:{missing}" assert not missing, f"结果缺少页面读取的字段:{missing}"
@@ -139,6 +139,59 @@ def main() -> int:
print(f"[contract] config_snapshot.selection={sel}") print(f"[contract] config_snapshot.selection={sel}")
print(f"[contract] config_snapshot.price_basis={basis}") 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 且带原因)—— # —— 未成交意图卡片(signal_history 中 filled=False 且带原因)——
rejects = [a for a in res["signal_history"] if not a["filled"] and a["reject_reason"]] rejects = [a for a in res["signal_history"] if not a["filled"] and a["reject_reason"]]
print(f"[contract] 未成交意图 {len(rejects)} 条,样例:" print(f"[contract] 未成交意图 {len(rejects)} 条,样例:"