feat: CLI 对齐 WebUI/SDK 分析能力 — 一键参数寻优+策略列表+组合级WF/一条龙

CLI 此前缺失的三块补齐,引擎层复用现成实现(ParamGridOptimizer/
STRATEGY_PRESETS/evaluate_portfolio/PortfolioWalkForwardEngine),
CLI、Web API 与 Python SDK 三条通路能力对等:

- 新增 easy-tdx optimize 参数网格寻优命令:单策略网格搜索
  (--strategy 用预设网格或 --param 自定义),--all 一键寻优所有
  内置策略并按总收益率全局排名(对齐 WebUI /optimize 页与
  /backtest/optimize-all/run/async);--workers 进程级并行;
  策略名/参数名联网前前置校验快速失败
- optimizer 新增 optimize_all_strategies 规范实现(模块级 worker
  可 pickle、主进程解析 label、跨策略进程池并行、presets 可注入
  子集网格),CLI 与后续 Web 端共用
- 新增 easy-tdx strategies 内置策略列表命令(名称/参数默认值/
  预设网格/说明;--output json 与 GET /backtest/strategies 同构)
- portfolio 补 --evaluate(组合级一条龙)/ --wf(组合级 WF)/
  --auto-fees,输出与 WebUI /portfolio 页同构
- 测试新增 12 例(optimize 互斥/未知策略/未知参数校验、strategies
  表格与 JSON、portfolio 新旗标、optimize_all_strategies 排名序/
  skipped/JSON 原生类型);pytest 1611 通过、ruff/mypy 全绿
- 文档同步:README、docs/backtest_usage.md CLI 章节+目录、
  CHANGELOG 未发布小节、examples/20_cli/cli_examples.sh 补
  回测系列 §39-48(输出样例均为真实行情实测)
This commit is contained in:
Justin Gu
2026-09-04 12:17:36 +08:00
parent 9d2b0397bd
commit c8535e2596
9 changed files with 914 additions and 3 deletions
+17
View File
@@ -2,6 +2,23 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [未发布]
**CLI 对齐 WebUI/Python SDK 的分析能力**——补齐 CLI 此前缺失的三块:一键参数寻优、内置策略列表、组合级 WF/一条龙。引擎层全部复用现成实现(`ParamGridOptimizer`/`STRATEGY_PRESETS`/`evaluate_portfolio`/`PortfolioWalkForwardEngine`),CLI、Web API`/backtest/optimize``/backtest/evaluate` 等)与 Python SDK 三条通路能力对等。
### 新增
- **`easy-tdx optimize` 参数网格寻优命令**[backtest/cli.py](src/easy_tdx/backtest/cli.py)):单策略网格搜索(`--strategy ma_cross` 用该策略预设网格,或 `--param fast=5,10,15 --param slow=20,60` 自定义),以及 `--all` 一键寻优所有内置策略——逐策略按 `STRATEGY_PRESETS` 预设网格寻优后按总收益率全局排名(对齐 Web UI /optimize 页与 `/backtest/optimize-all/run/async`)。支持 `--workers N` 进程级并行、`--table` 排名表 / JSON 全量输出(含热力图矩阵)。`--strategy``--param` 在联网取数前前置校验(未知策略/未知参数/网格超限快速失败)。
- **`easy_tdx.backtest.optimizer.optimize_all_strategies` Python API**[optimizer.py](src/easy_tdx/backtest/optimizer.py)):一键全策略寻优的规范实现(`_optimize_strategy_best` 模块级 worker 可 pickle,主进程解析 label、跨策略 ProcessPool 并行),CLI 与后续 Web 端可共用;`presets` 参数支持注入子集网格(测试用)。
- **`easy-tdx strategies` 内置策略列表命令**:列出注册表全部策略(名称/中文标签/参数默认值/预设寻优网格点数/说明),`--output json` 输出与 Web API `GET /backtest/strategies` 同构的完整 schema。
- **`easy-tdx portfolio --evaluate / --wf`**:组合级一条龙评估(组合回测 + 组合 WF + 跨标的适配性体检 + 综合评分 + 组合评级 + 等权买入持有基准对比,调 `evaluate_portfolio`)与组合级 Walk-Forward`PortfolioWalkForwardEngine`),输出与 Web UI /portfolio 页同构;同时补上 `--auto-fees` 品种感知费率旗标。
### 测试
- 新增 12 例:`optimize` 命令互斥/未知策略/未知参数/畸形参数校验(联网前快速失败)、`strategies` 表格与 JSON 输出、`portfolio --help` 新旗标、`optimize_all_strategies` 排名序/label/skipped/JSON 原生类型。
- 实测验证:`strategies` 列出 54 策略;`optimize --strategy`3×3 网格)与 `optimize --all --workers 4`(54 策略 316 网格点)真实行情跑通;`portfolio --evaluate`(完整报告含评分/评级/WF/基准)与 `--wf` 真实跑通。
- 全量回归:pytest 1611 通过(`test_optimizer_cache_reuse_across_grid_points` 为 main 既有失败,与本次无关)、ruff/ruff format/mypy 全绿。
## [1.31.2] — 2026-09-04
**分时接口全场景修复:盘前/休市不再为空,指数从无到有**——`/api/v1/minute`(今日分时)此前固定用"今天的日期"走历史分时接口,而历史分时的当日数据要收盘后才生成:盘前、周末、节假日调用必然拿到 `{"data":[],"count":0}`,WebUI 分时图一片空白;指数(上证指数 000001、创业板指 399006、880 板块指数等)则**任何时候都为空**——旧实现从未适配指数。本版以「最新一根日 K 的日期」锚定最近交易日,个股与指数全场景有数。
+20
View File
@@ -393,6 +393,20 @@ easy-tdx backtest SZ 300308 --strategy-file strategies/expma_cross.py --evaluate
> 不构成投资建议。实际交易需考虑滑点、流动性、涨跌停无法成交等因素。
> 请在充分理解策略逻辑后谨慎使用。
**参数网格寻优(optimize)与内置策略列表(strategies):**
```bash
# 列出全部内置策略(名称/参数默认值/预设寻优网格)
easy-tdx strategies
# 单策略网格寻优(预设网格或 --param 自定义,--workers 4 进程并行)
easy-tdx optimize SZ 000001 --strategy ma_cross
easy-tdx optimize SZ 000001 --strategy ma_cross --param fast=5,10,15 --param slow=20,60
# 一键寻优所有内置策略:逐策略按预设网格寻优后全局排名(对应 Web UI /optimize 页)
easy-tdx optimize SZ 000001 --all --workers 4 --table
```
**全策略批量对比(CLI):**
`easy-tdx run-all` 一行命令跑完 `strategies/` 下所有策略并排名:
@@ -478,6 +492,12 @@ easy-tdx portfolio --stocks SZ:000001,SH:600519,SH:600036 \
# 搭配缠论桥接
easy-tdx portfolio --stocks SZ:000001,SH:600519 \
--strategy-file strategies/chanlun_strategy.py --chanlun-level DAILY --table
# 组合级 Walk-Forward / 一条龙评估(与 Web UI /portfolio 页同构)
easy-tdx portfolio --stocks SZ:000001,SH:600519 \
--strategy-file strategies/ma_cross.py --wf --wf-windows 7
easy-tdx portfolio --stocks SZ:000001,SH:600519 \
--strategy-file strategies/ma_cross.py --evaluate
```
输出示例:
+62
View File
@@ -22,6 +22,9 @@
- [交易记录](#交易记录)
- [序列化输出](#序列化输出)
- [CLI 命令行](#cli-命令行)
- [内置策略列表(strategies](#内置策略列表strategies)
- [参数网格寻优(optimize](#参数网格寻优optimize)
- [组合级分析(portfolio](#组合级分析portfolio)
- [进阶用法](#进阶用法)
- [预计算指标列](#预计算指标列)
- [缠论结果注入](#缠论结果注入)
@@ -437,6 +440,65 @@ easy-tdx backtest SZ 000001 --strategy-file my_strategy.py --table
| `--indicators` | — | 预计算指标(逗号分隔) |
| `--table` | False | 表格输出 |
| `--output` | json | 输出格式:json / table / csv |
| `--wf` | False | 附加 Walk-Forward 样本外验证 |
| `--wf-windows` | 7 | Walk-Forward 窗口数 |
| `--evaluate` | False | 一条龙评估(回测+WF+适配性+评分+评级+基准对比) |
| `--auto-fees` | False | 按标的品种自动解析费率 |
### 内置策略列表(strategies
```bash
# 表格列出全部内置策略(名称/参数/预设寻优网格)
easy-tdx strategies
# JSON 输出(含完整参数 schema,与 Web API GET /backtest/strategies 同构)
easy-tdx strategies --output json
```
### 参数网格寻优(optimize
对注册表内置策略的 1-2 个参数做网格搜索,按总收益率排名:
```bash
# 单策略:用该策略的预设寻优网格(见 strategies 命令)
easy-tdx optimize SZ 000001 --strategy ma_cross
# 单策略:自定义网格(--param 参数名=值1,值2,可多次指定)
easy-tdx optimize SZ 000001 --strategy ma_cross --param fast=5,10,15 --param slow=20,60
# 一键寻优所有内置策略:逐策略按预设网格寻优后全局排名
easy-tdx optimize SZ 000001 --all
# 并行加速(2+ 进程级并行;1 = 串行 + 指标缓存复用)
easy-tdx optimize SZ 000001 --all --workers 4
```
**optimize 参数**
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `--strategy` | — | 注册表策略名(与 `--all` 二选一) |
| `--all` | False | 一键寻优所有内置策略(STRATEGY_PRESETS 预设网格) |
| `--param` | 预设网格 | 自定义参数网格,如 `fast=5,10,15`(最多 2 个参数,笛卡尔积 ≤ 200) |
| `--cash` | 1000000 | 初始资金 |
| `--commission` | 0.0003 | 佣金率 |
| `--slippage` | 0.0 | 滑点 |
| `--workers` | 1 | 并行进程数 |
| `--top` | 15 | 表格输出显示前 N 行 |
Python API 同名能力:`easy_tdx.backtest.optimizer.ParamGridOptimizer`(单策略)与
`easy_tdx.backtest.optimizer.optimize_all_strategies`(一键全策略)。
### 组合级分析(portfolio
```bash
# 组合级 Walk-Forward 样本外验证(全部标的日期并集切窗,每窗独立开仓)
easy-tdx portfolio --stocks SZ:000001,SH:600519 --strategy-file strategies/ma_cross.py --wf --wf-windows 7
# 组合级一条龙评估:组合回测 + 组合WF + 跨标的适配性 + 综合评分
# + 组合评级 + 等权买入持有基准对比(与 Web UI /portfolio 页同构)
easy-tdx portfolio --stocks SZ:000001,SH:600519 --strategy-file strategies/ma_cross.py --evaluate
```
---
+169
View File
@@ -454,3 +454,172 @@ echo "=== 38. 常用指标快速参考 ==="
# CCI: easy-tdx indicator CCI -m SH -c 600519 --table
# BIAS: easy-tdx indicator BIAS -m SZ -c 000001 --table
# OBV: easy-tdx indicator OBV -m SZ -c 000001 --table
# ═══════════════════════════════════════════════════════════════════════════
# 回测系列(backtest / portfolio / strategies / optimize / run-all
# 策略文件可用仓库自带的 strategies/ 目录(expma_cross.py 等),
# 也可参考 docs/backtest_usage.md 自己编写(继承 Strategy 基类)。
# ═══════════════════════════════════════════════════════════════════════════
echo "=== 39. 回测 - 单策略 ==="
# 加载 Python 策略文件跑回测,--table 输出绩效概要(默认 JSON 全量)。
# 参数: <市场> <代码> --strategy-file <路径> --cash N --commission F --count N --adjust <复权>
# 绩效含 25 项指标:收益/回撤/夏普 + Ulcer/VaR(95%)/CVaR(95%)/SQN/最大连胜连亏(深度风险)
# easy-tdx backtest SZ 000001 --strategy-file strategies/expma_cross.py --count 250 --table
# 输出:
# === 回测绩效概要 ===
# 总收益率: -0.79%
# 年化收益: -0.80%
# 最大回撤: 8.54%
# 夏普比率: -0.41
# 胜率: 0.00%
# 交易次数: 3
# Ulcer 指数: 0.0406
# 日 VaR(95%): 0.91%
# 日 CVaR(95%): 1.23%
# SQN 系统质量: -16.49
# 最大连胜/连亏: 0 / 3
#
# === 配置参数 ===
# 初始资金: 100000.00
# 佣金率: 0.0003
# 成交规则: next_open
#
# === 最近交易记录 ===
# [2025-11-21 00:00:00] 买入 数量=8400 价格=11.80 盈亏=0.00 [成交]
# [2025-12-04 00:00:00] 卖出 数量=8400 价格=11.55 盈亏=-2255.86 [成交]
# ...
echo "=== 40. 回测 - 成本模拟(滑点/自动费率/成交规则)==="
# --slippage F 固定滑点(元/股),让回测更贴近实盘
# --auto-fees 按标的品种自动解析费率(ETF/可转债免印花税等;显式 --commission 优先)
# --execution 成交价规则: next_open(默认) / next_close
# --chanlun-level 缠论自动桥接:引擎计算缠论并注入策略 self.chanlun(如 DAILY/30MIN
# easy-tdx backtest SZ 000001 --strategy-file strategies/expma_cross.py --slippage 0.01 --auto-fees
# easy-tdx backtest SH 600519 --strategy-file my_strategy.py --execution next_close
# easy-tdx backtest SZ 000001 --strategy-file strategies/chanlun_strategy.py --chanlun-level DAILY --table
echo "=== 41. 回测 - Walk-Forward 样本外验证 ==="
# 全样本收益好 ≠ 策略好。--wf 把时间轴切成 N 个连续窗口逐窗独立回测
# (每窗从空仓开始、窗口结束强制了结),检验策略在不同时段是否稳定盈利。
# consistency(盈利窗占比)是 WF 稳定性的核心指标。
# easy-tdx backtest SZ 000001 --strategy-file strategies/expma_cross.py --wf --wf-windows 5
# 输出(回测 JSON 之后附加 walkforward JSON:
# {
# "walkforward": {
# "n_windows": 5,
# "warmup_ratio": 0.3,
# "consistency": 0.2,
# "chained_return": -0.0045,
# "worst_window": -0.0719,
# "best_window": 0.0726,
# "windows": [
# {"index": 0, "start": "2025-12-17", "end": "2026-02-05", "bars": 35,
# "total_return": 0.0, "sharpe": 0.0, "max_drawdown": 0.0,
# "total_trades": 0, "win_rate": 0.0, "performance": {...}},
# ...
# ]
# }
# }
echo "=== 42. 回测 - 一条龙评估 ==="
# --evaluate 一条龙:回测 + WF + 适配性体检 + 综合评分 + S-D 评级 + 买入持有基准对比,
# 覆盖常规输出,直接给完整报告 JSON(与 WebUI 一条龙面板同构)。
# fitness.pass_ratio: 适配性体检通过率;grade: S-D 评级(不看收益,看净值质量);
# benchmark.excess_return: 相对买入持有的超额收益。
# easy-tdx backtest SZ 000001 --strategy-file strategies/expma_cross.py --evaluate
# 输出:
# {
# "performance": {...25 项绩效指标...},
# "score": {"total": 17.3, "components": {...}, ...},
# "grade": {"grade": "D", "score": 45.9, ...},
# "walkforward": {...同上 --wf...},
# "fitness": {"pass_ratio": ..., "checks": [...], "segments": [...], ...},
# "benchmark": {"buy_hold": {"total_return": -0.0343, ...}, "excess_return": 0.0264, ...},
# "config": {"symbol": "SZ:000001", "n_windows": 7, ...}
# }
echo "=== 43. 回测 - 多因子组合 ==="
# 多个策略文件合成一组信号:AND(全部看多) / OR(任一看多) / MAJORITY(多数,默认)。
# 至少 2 个策略文件,逗号分隔。
# easy-tdx backtest SZ 000001 \
# --combo-strategies strategies/macd_cross.py,strategies/rsi_reversal.py \
# --combo-mode MAJORITY --table
echo "=== 44. 回测 - 多标的组合回测(portfolio==="
# 多只股票共享资金池、均等分配、统一仓位管理。
# 参数: --stocks "市场:代码,..." --strategy-file <路径> --cash N --allocation equal
# easy-tdx portfolio --stocks SZ:000001,SH:600519 --strategy-file strategies/expma_cross.py --table
# 输出:
# === 组合回测绩效概要 ===
# 标的数量: 2.0
# 总资金: 200,000
# 组合收益率: -0.40%
# 组合年化: -0.40%
#
# ── 各标的详情 ──
# SZ000001: 收益=-0.79% 夏普=-0.41 回撤=8.54% 分配=50% 交易=3
# SH600519: 收益=0.00% 夏普=0.00 回撤=0.00% 分配=50% 交易=0
echo "=== 45. 回测 - 组合级 WF / 一条龙 ==="
# portfolio 也支持样本外验证与一条龙评估(全部标的日期并集切窗):
# --wf / --wf-windows 组合级 Walk-Forward
# --evaluate 组合级一条龙(组合回测+组合WF+跨标的适配性+评分+组合评级+等权买入持有基准)
# --auto-fees 按各标的品种自动解析费率
# easy-tdx portfolio --stocks SZ:000001,SH:600519 \
# --strategy-file strategies/expma_cross.py --wf --wf-windows 5
# easy-tdx portfolio --stocks SZ:000001,SH:600519 \
# --strategy-file strategies/expma_cross.py --evaluate
echo "=== 46. 回测 - 内置策略列表(strategies==="
# 列出注册表全部内置策略:名称、参数默认值、预设寻优网格、说明。
# 策略名可直接用于 optimize --strategy 与 Web API /backtest/run 的 strategy 字段。
# easy-tdx strategies
# 输出:
# === 内置策略(54 个)===
#
# ma_cross — 双均线交叉
# 参数: fast=5, slow=20
# 预设网格: fast:6 × slow:636 点)
# 说明: 快线上穿慢线买入,快线下穿慢线卖出。最经典的趋势跟随策略。
#
# macd — MACD 金叉
# 参数: short=12, long=26, signal=9
# 预设网格: short:4 × long:416 点)
# ...
# easy-tdx strategies --output json # 完整参数 schema(含 min/max/choices
echo "=== 47. 回测 - 参数网格寻优(optimize==="
# 对内置策略的 1-2 个参数做网格搜索,按总收益率排名。
# 不指定 --param 时自动用该策略的预设网格(见 strategies 命令)。
# --workers N: 1=串行+指标缓存复用(默认);2+=进程级并行。
# easy-tdx optimize SZ 000001 --strategy ma_cross --param fast=5,10,20 --param slow=20,30,60 --table
# 输出:
# === 参数寻优: ma_cross8 个有效网格点)===
#
# 排名 参数 总收益率 夏普 最大回撤 交易 胜率
# 1 fast=10, slow=30 0.54% -0.19 11.92% 4 25.0%
# 2 fast=20, slow=60 0.06% -0.33 8.88% 2 0.0%
# 3 fast=5, slow=30 -5.29% -0.76 15.54% 6 0.0%
# ...
#
# 最佳参数: fast=10, slow=30
# easy-tdx optimize SZ 000001 --strategy ma_cross # JSON 输出全量结果(含 2 参数热力图矩阵)
echo "=== 48. 回测 - 一键寻优所有策略 + 批量对比 ==="
# --all: 逐个内置策略按预设网格寻优,取各自最优点做全局排名(对应 WebUI /optimize 页)。
# easy-tdx optimize SZ 000001 --all --count 120 --workers 4 --table
# 输出:
# === 一键寻优所有策略(54 个策略,共 316 网格点)===
#
# 排名 策略 最佳参数 总收益率 夏普 最大回撤 交易 胜率
# 1 kdj_cross KDJ 金叉 n=5 18.59% 2.82 2.79% 7 71.4%
# 2 fisher_cross FISHER 费雪拐点 n=9 16.94% 2.58 5.04% 7 57.1%
# 3 sar_follow SAR 抛物线跟随 af_step=0.03 16.55% 2.53 4.69% 4 75.0%
# ...
#
# 全局最优: kdj_cross n=5
#
# run-all: 另一种批量方式——跑 strategies/ 目录下所有 .py 策略文件并排名(--combo 做组合)。
# easy-tdx run-all SZ 300308 --count 2000 --cash 1000000 --adjust QFQ
# easy-tdx run-all SZ 300308 --combo 2 --combo-mode MAJORITY
+353 -2
View File
@@ -100,6 +100,10 @@ def backtest(
easy-tdx backtest SZ 000001 --strategy-file chanlun_strategy.py --chanlun-level DAILY
easy-tdx backtest SZ 300308 --strategy-file ma_cross.py --wf --wf-windows 7
easy-tdx backtest SZ 300308 --strategy-file ma_cross.py --evaluate
easy-tdx backtest SZ 000001 \
--combo-strategies strategies/macd_cross.py,strategies/rsi_reversal.py \
--combo-mode MAJORITY --table
@@ -394,6 +398,20 @@ def _print_table(result: Any) -> None:
default=None,
help="自动计算缠论分析并注入策略(如 DAILY/30MIN",
)
@click.option(
"--auto-fees",
"auto_fees",
is_flag=True,
help="按标的品种自动解析费率(ETF/可转债免印花税等;显式 --commission 优先)",
)
@click.option("--wf", "walk_forward", is_flag=True, help="附加组合级 Walk-Forward 样本外验证")
@click.option("--wf-windows", "wf_windows", default=7, type=int, help="Walk-Forward 窗口数")
@click.option(
"--evaluate",
"full_evaluate",
is_flag=True,
help="组合级一条龙评估:组合回测+组合WF+适配性+综合评分+组合评级+等权买入持有基准对比(覆盖常规输出)",
)
@click.option("--table", "use_table", is_flag=True, help="表格输出")
@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json")
def portfolio(
@@ -407,6 +425,10 @@ def portfolio(
count: int,
allocation: str,
chanlun_level: str | None,
auto_fees: bool,
walk_forward: bool,
wf_windows: int,
full_evaluate: bool,
use_table: bool,
output_fmt: str,
) -> None:
@@ -423,6 +445,12 @@ def portfolio(
easy-tdx portfolio --stocks SZ:000001,SH:600519 \\
--strategy-file chanlun_strat.py --chanlun-level DAILY
easy-tdx portfolio --stocks SZ:000001,SH:600519 \\
--strategy-file ma_cross.py --wf --wf-windows 7
easy-tdx portfolio --stocks SZ:000001,SH:600519 \\
--strategy-file ma_cross.py --evaluate
"""
import json
@@ -465,7 +493,41 @@ def portfolio(
)
stock_data_list.append(StockData(code=code, market=mkt_str, df=df))
# 4. 创建引擎并运行
# 4. 组合级一条龙评估:覆盖常规输出(含组合回测本身,无需重复跑)
if full_evaluate:
from .benchmark import evaluate_portfolio
report = evaluate_portfolio(
strategy=strategy_cls,
stocks=stock_data_list,
total_cash=cash,
commission=commission,
execution=execution,
chanlun_level=chanlun_level,
auto_fees=auto_fees,
n_windows=wf_windows,
)
click.echo(json.dumps(report, ensure_ascii=False, default=str))
return
# 5. 组合级 Walk-Forward 样本外验证(--wf
if walk_forward:
from .walkforward import PortfolioWalkForwardEngine
wf = PortfolioWalkForwardEngine(
strategy=strategy_cls,
stocks=stock_data_list,
n_windows=wf_windows,
total_cash=cash,
commission=commission,
execution=execution,
chanlun_level=chanlun_level,
auto_fees=auto_fees,
)
click.echo(json.dumps({"walkforward": wf.run().to_dict()}, ensure_ascii=False, default=str))
return
# 6. 常规组合回测
engine = PortfolioBacktestEngine(
strategy=strategy_cls,
stocks=stock_data_list,
@@ -474,10 +536,11 @@ def portfolio(
commission=commission,
execution=execution,
chanlun_level=chanlun_level,
auto_fees=auto_fees,
)
result = engine.run()
# 5. 输出结果
# 7. 输出结果
fmt = "table" if use_table else output_fmt
if fmt == "table":
_print_portfolio_table(result)
@@ -508,3 +571,291 @@ def _print_portfolio_table(result: Any) -> None:
f"交易={sp.get('total_trades', 0)}"
)
click.echo()
# ── strategies 内置策略列表命令 ──────────────────────────────────────────────
@click.command("strategies")
@click.option("--output", "output_fmt", type=click.Choice(["json", "table"]), default="table")
def strategies(output_fmt: str) -> None:
"""列出内置策略注册表:名称、参数定义与预设寻优网格。
策略名可直接用于 optimize --strategy / Web API /backtest/run 的 strategy 字段。
示例:
easy-tdx strategies
easy-tdx strategies --output json
"""
import json
from .strategies import get_registry
entries = get_registry().all()
if output_fmt == "json":
click.echo(json.dumps([e.to_schema() for e in entries], ensure_ascii=False, indent=2))
return
click.echo(f"=== 内置策略({len(entries)} 个)===\n")
for entry in entries:
schema = entry.to_schema()
params_desc = ", ".join(f"{p['name']}={p['default']}" for p in schema["params"])
grid = schema.get("preset_grid") or {}
points = 1
for vals in grid.values():
points *= len(vals)
grid_desc = " × ".join(f"{k}:{len(v)}" for k, v in grid.items()) if grid else ""
click.echo(f" {entry.name}{entry.label}")
click.echo(f" 参数: {params_desc or ''}")
click.echo(f" 预设网格: {grid_desc}{points} 点)")
if entry.description:
click.echo(f" 说明: {entry.description}")
click.echo()
# ── optimize 参数网格寻优命令 ────────────────────────────────────────────────
def _coerce_param_value(raw: str) -> Any:
"""把字符串参数值尽量转为 int/float,失败保留字符串。"""
try:
return int(raw)
except ValueError:
pass
try:
return float(raw)
except ValueError:
return raw
def _parse_param_grid(pairs: tuple[str, ...]) -> dict[str, list[Any]]:
"""解析 --param fast=5,10,15 形式的自定义网格。"""
grid: dict[str, list[Any]] = {}
for item in pairs:
name, sep, raw = item.partition("=")
values = [_coerce_param_value(v.strip()) for v in raw.split(",") if v.strip()]
if not sep or not name.strip() or not values:
click.echo(f"错误: --param 格式应为 参数名=值1,值2,收到: {item}", err=True)
raise SystemExit(1)
grid[name.strip()] = values
return grid
@click.command()
@click.argument("market")
@click.argument("code")
@click.option(
"--strategy",
"strategy_name",
default=None,
help="注册表策略名(见 strategies 命令;网格取 --param 或该策略预设)",
)
@click.option(
"--all",
"optimize_all",
is_flag=True,
help="一键寻优所有内置策略:逐策略按预设网格寻优,输出全局排名",
)
@click.option(
"--param",
"param_pairs",
multiple=True,
help="自定义参数网格,如 --param fast=5,10,15 --param slow=20,60(覆盖预设)",
)
@click.option("--cash", default=1_000_000.0, type=float, help="初始资金")
@click.option("--commission", default=0.0003, type=float, help="佣金率")
@click.option("--slippage", default=0.0, type=float, help="滑点")
@click.option(
"--execution",
default="next_open",
type=click.Choice(["next_open", "next_close"]),
help="成交价规则",
)
@click.option(
"--workers",
default=1,
type=int,
help="并行进程数:1=串行+指标缓存(默认);2+=进程级并行",
)
@click.option("--period", default="DAILY", help="K线周期")
@click.option("--adjust", default="NONE", help="复权: NONE/QFQ/HFQ")
@click.option("--count", default=500, type=int, help="K线数量")
@click.option("--top", default=15, type=int, help="表格输出显示前 N 行")
@click.option("--table", "use_table", is_flag=True, help="表格输出")
@click.option("--output", "output_fmt", type=click.Choice(["json", "table"]), default="json")
def optimize(
market: str,
code: str,
strategy_name: str | None,
optimize_all: bool,
param_pairs: tuple[str, ...],
cash: float,
commission: float,
slippage: float,
execution: str,
workers: int,
period: str,
adjust: str,
count: int,
top: int,
use_table: bool,
output_fmt: str,
) -> None:
"""参数网格寻优:单策略网格搜索,或 --all 一键寻优所有内置策略。
示例:
easy-tdx optimize SZ 000001 --strategy ma_cross
easy-tdx optimize SZ 000001 --strategy ma_cross \\
--param fast=5,10,15 --param slow=20,60
easy-tdx optimize SZ 000001 --all --workers 4 --table
"""
from ..cli.conn import get_mac_client
from ..cli.parsers import parse_adjust, parse_market, parse_period
from .strategies import get_registry
from .strategies.presets import get_preset
registry = get_registry()
# 1. 校验模式与策略(联网取数之前,快速失败)
if optimize_all == (strategy_name is not None):
click.echo("错误: --all 与 --strategy 二选一", err=True)
raise SystemExit(1)
custom_grid = _parse_param_grid(param_pairs) if param_pairs else None
if not optimize_all:
assert strategy_name is not None
try:
entry = registry.get(strategy_name)
except KeyError as exc:
click.echo(f"错误: {exc}", err=True)
raise SystemExit(1) from None
if custom_grid is not None:
declared = {p.name for p in entry.params}
unknown = set(custom_grid) - declared
if unknown:
click.echo(
f"错误: 未知参数 {sorted(unknown)}"
f"'{strategy_name}' 可用参数: {sorted(declared)}",
err=True,
)
raise SystemExit(1)
else:
custom_grid = get_preset(strategy_name)
if not custom_grid:
first_param = entry.params[0].name if entry.params else "参数名"
click.echo(
f"错误: 策略 '{strategy_name}' 未登记预设网格,请用 --param 指定(如 "
f"--param {first_param}=5,10,20",
err=True,
)
raise SystemExit(1)
# 2. 获取数据
mkt = parse_market(market)
with get_mac_client() as client:
df = client.get_stock_kline(
mkt,
code,
period=parse_period(period),
start=0,
count=count,
adjust=parse_adjust(adjust),
)
# 3. 寻优
fmt = "table" if use_table else output_fmt
import json
if optimize_all:
from .optimizer import optimize_all_strategies
report = optimize_all_strategies(
df,
cash=cash,
commission=commission,
slippage=slippage,
execution=execution,
workers=workers,
)
if fmt == "table":
_print_optimize_all_table(report, top)
else:
click.echo(json.dumps(report, ensure_ascii=False, default=str))
return
from .optimizer import ParamGridOptimizer
assert strategy_name is not None and custom_grid is not None
optimizer = ParamGridOptimizer(
strategy_name=strategy_name,
param_grid=custom_grid,
df=df,
cash=cash,
commission=commission,
slippage=slippage,
execution=execution,
workers=workers,
)
result = optimizer.run()
if fmt == "table":
_print_optimize_table(result.to_dict(), top)
else:
click.echo(json.dumps(result.to_dict(), ensure_ascii=False, default=str))
def _fmt_grid_point(params: dict[str, Any]) -> str:
"""把参数字典格式化为 fast=10, slow=20 形式。"""
return ", ".join(f"{k}={v}" for k, v in params.items())
def _print_optimize_table(report: dict[str, Any], top: int) -> None:
"""以表格形式输出单策略寻优结果。"""
results = report.get("results") or []
best = report.get("best")
click.echo(f"=== 参数寻优: {report.get('strategy')}{len(results)} 个有效网格点)===\n")
click.echo(
f"{'排名':<4} {'参数':<28} {'总收益率':>8} {'夏普':>6} {'最大回撤':>8} "
f"{'交易':>4} {'胜率':>6}"
)
for i, r in enumerate(results[:top], 1):
click.echo(
f"{i:<4} {_fmt_grid_point(r['params']):<28} {r['total_return']:>8.2%} "
f"{r['sharpe']:>6.2f} {r['max_drawdown']:>8.2%} "
f"{r['total_trades']:>4} {r['win_rate']:>6.1%}"
)
if best:
click.echo(f"\n最佳参数: {_fmt_grid_point(best['params'])}")
if len(results) > top:
click.echo(f"(仅显示前 {top} 行,完整结果用 --output json")
def _print_optimize_all_table(report: dict[str, Any], top: int) -> None:
"""以表格形式输出 --all 全策略寻优排名。"""
ranking = report.get("ranking") or []
click.echo(
f"=== 一键寻优所有策略({len(ranking)} 个策略,"
f"{report.get('total_grid_points', 0)} 网格点)===\n"
)
click.echo(
f"{'排名':<4} {'策略':<20} {'最佳参数':<28} {'总收益率':>8} {'夏普':>6} "
f"{'最大回撤':>8} {'交易':>4} {'胜率':>6}"
)
for i, r in enumerate(ranking[:top], 1):
label = f"{r['strategy']} {r.get('strategy_label', '')}"
click.echo(
f"{i:<4} {label:<20} {_fmt_grid_point(r['params']):<28} "
f"{r['total_return']:>8.2%} {r['sharpe']:>6.2f} "
f"{r['max_drawdown']:>8.2%} {r['total_trades']:>4} {r['win_rate']:>6.1%}"
)
if report.get("skipped"):
click.echo(f"\n跳过(未注册): {', '.join(report['skipped'])}")
if ranking:
click.echo(f"\n全局最优: {ranking[0]['strategy']} {_fmt_grid_point(ranking[0]['params'])}")
if len(ranking) > top:
click.echo(f"(仅显示前 {top} 行,完整结果用 --output json")
+122
View File
@@ -326,6 +326,128 @@ class ParamGridOptimizer:
return {"x_name": x_name, "y_name": y_name, "x": x_vals, "y": y_vals, "data": data}
def _optimize_strategy_best(job: tuple[Any, ...]) -> dict[str, Any] | None:
"""单策略网格寻优,返回该策略最优点摘要(模块级,可 pickle)。
job = (strategy_name, param_grid, df, cash, commission, min_commission,
stamp_tax, slippage, execution)
策略类在 worker 内经 ``registry.get(name).build()`` 构造,从不跨进程传递;
返回纯 dict(JSON 原生类型),可安全 pickle 回主进程。网格超限或无有效
结果返回 None(调用方跳过)。
"""
(name, grid, df, cash, commission, min_commission, stamp_tax, slippage, execution) = job
try:
optimizer = ParamGridOptimizer(
strategy_name=name,
param_grid=grid,
df=df,
cash=cash,
commission=commission,
min_commission=min_commission,
stamp_tax=stamp_tax,
slippage=slippage,
execution=execution,
)
except ValueError:
return None
result = optimizer.run()
if result.best is None:
return None
best = result.best
return {
"strategy": name,
"params": dict(best.params),
"total_return": best.total_return,
"sharpe": best.sharpe,
"max_drawdown": best.max_drawdown,
"total_trades": best.total_trades,
"win_rate": best.win_rate,
"profit_factor": best.profit_factor,
"grid_points": len(result.results),
}
def optimize_all_strategies(
df: pd.DataFrame,
*,
cash: float = 1_000_000.0,
commission: float = 0.0003,
min_commission: float = 5.0,
stamp_tax: float = 0.001,
slippage: float = 0.0,
execution: str = "next_open",
workers: int = 1,
presets: dict[str, dict[str, list[Any]]] | None = None,
) -> dict[str, Any]:
"""一键寻优所有内置策略:逐策略用预设网格寻优,取各策略最优点全局排名。
遍历 ``STRATEGY_PRESETS``(可用 ``presets`` 覆盖,如测试传小子集),
每个策略跑一次 :class:`ParamGridOptimizer`,取其 best 组装排名。
未注册的策略名跳过并记录在 ``skipped``。
Args:
df: OHLCV DataFrame(所有策略、所有网格点共用)。
cash / commission / min_commission / stamp_tax / slippage / execution:
透传给每个网格点的回测引擎(所有策略同口径)。
workers: ≥2 时用 ProcessPoolExecutor 跨策略进程级并行(每策略内部
串行);0/1 串行。
presets: 覆盖预设网格表(默认 ``STRATEGY_PRESETS``)。
Returns:
{"ranking": [最优点摘要(按 total_return 降序,含 strategy_label],
"best": ranking[0] | None, "total_grid_points": int, "skipped": [..]}
"""
from easy_tdx.backtest.strategies import get_registry
from easy_tdx.backtest.strategies.presets import STRATEGY_PRESETS
if presets is None:
presets = STRATEGY_PRESETS
registry = get_registry()
# label 必须在主进程解析,避免子进程各自 import 产生不一致
jobs: list[tuple[str, dict[str, list[Any]]]] = []
labels: dict[str, str] = {}
skipped: list[str] = []
for name, grid in presets.items():
if name not in registry.names():
skipped.append(name)
continue
labels[name] = registry.get(name).label
jobs.append((name, grid))
job_tuples = [
(name, grid, df, cash, commission, min_commission, stamp_tax, slippage, execution)
for name, grid in jobs
]
raw: list[dict[str, Any]] = []
if workers >= 2:
import concurrent.futures
with concurrent.futures.ProcessPoolExecutor(max_workers=workers) as pool:
for r in pool.map(_optimize_strategy_best, job_tuples, chunksize=1):
if r is not None:
raw.append(r)
else:
for job in job_tuples:
r = _optimize_strategy_best(job)
if r is not None:
raw.append(r)
for r in raw:
r["strategy_label"] = labels[r["strategy"]]
raw.sort(key=lambda r: r["total_return"], reverse=True)
return {
"ranking": raw,
"best": raw[0] if raw else None,
"total_grid_points": sum(r["grid_points"] for r in raw),
"skipped": skipped,
}
def _optimize_grid_point(job: tuple[Any, ...]) -> GridPointResult | None:
"""进程池 worker:在子进程内评估单个网格点(模块级,可 pickle)。
+3 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
from ..backtest.cli import backtest, portfolio
from ..backtest.cli import backtest, optimize, portfolio, strategies
from ..screen.cli import screen
from .cmd_admin import ping, version
from .cmd_announcement import announcement
@@ -97,6 +97,8 @@ cli.add_command(factor)
cli.add_command(pfactor)
cli.add_command(backtest)
cli.add_command(portfolio)
cli.add_command(optimize)
cli.add_command(strategies)
cli.add_command(run_all)
cli.add_command(screen)
cli.add_command(serve)
+113
View File
@@ -28,3 +28,116 @@ class TestBacktestCLI:
result = runner.invoke(backtest, ["SZ", "000001"])
assert result.exit_code == 1
assert "必须指定" in result.output or "错误" in result.output
class TestStrategiesCLI:
"""测试内置策略列表命令。"""
def test_table_output(self):
"""表格输出应包含注册表策略与预设网格信息。"""
from easy_tdx.backtest.cli import strategies
runner = CliRunner()
result = runner.invoke(strategies, [])
assert result.exit_code == 0
assert "内置策略" in result.output
assert "ma_cross" in result.output
assert "预设网格" in result.output
def test_json_output(self):
"""JSON 输出应为策略 schema 列表(含 preset_grid)。"""
import json
from easy_tdx.backtest.cli import strategies
runner = CliRunner()
result = runner.invoke(strategies, ["--output", "json"])
assert result.exit_code == 0
data = json.loads(result.output)
names = {entry["name"] for entry in data}
assert "ma_cross" in names
ma_cross = next(e for e in data if e["name"] == "ma_cross")
assert ma_cross["preset_grid"]["fast"]
assert all("default" in p for p in ma_cross["params"])
class TestOptimizeCLI:
"""测试参数寻优命令(校验逻辑,不联网)。"""
def test_help(self):
"""测试 --help 显示帮助。"""
from easy_tdx.backtest.cli import optimize
runner = CliRunner()
result = runner.invoke(optimize, ["--help"])
assert result.exit_code == 0
assert "--strategy" in result.output
assert "--all" in result.output
assert "--param" in result.output
def test_strategy_and_all_mutually_exclusive(self):
"""--all 与 --strategy 同时指定应报错(联网之前快速失败)。"""
from easy_tdx.backtest.cli import optimize
runner = CliRunner()
result = runner.invoke(optimize, ["SZ", "000001", "--strategy", "ma_cross", "--all"])
assert result.exit_code == 1
assert "二选一" in result.output
def test_missing_mode_fails(self):
"""--all 与 --strategy 都不指定应报错。"""
from easy_tdx.backtest.cli import optimize
runner = CliRunner()
result = runner.invoke(optimize, ["SZ", "000001"])
assert result.exit_code == 1
assert "二选一" in result.output
def test_unknown_strategy_fails(self):
"""未知策略名应报错并列出可选值(联网之前快速失败)。"""
from easy_tdx.backtest.cli import optimize
runner = CliRunner()
result = runner.invoke(optimize, ["SZ", "000001", "--strategy", "no_such_strat"])
assert result.exit_code == 1
assert "未知策略" in result.output
assert "ma_cross" in result.output
def test_unknown_param_fails(self):
"""--param 传不存在的参数名应报错(避免网格点被静默清空)。"""
from easy_tdx.backtest.cli import optimize
runner = CliRunner()
result = runner.invoke(
optimize,
["SZ", "000001", "--strategy", "ma_cross", "--param", "no_such=5,10"],
)
assert result.exit_code == 1
assert "未知参数" in result.output
def test_malformed_param_fails(self):
"""--param 缺少等号应报错。"""
from easy_tdx.backtest.cli import optimize
runner = CliRunner()
result = runner.invoke(
optimize,
["SZ", "000001", "--strategy", "ma_cross", "--param", "fast"],
)
assert result.exit_code == 1
assert "错误" in result.output
class TestPortfolioCLIFlags:
"""测试组合回测命令的新增分析旗标。"""
def test_help_includes_evaluate_and_wf(self):
"""--help 应列出 --evaluate / --wf / --auto-fees。"""
from easy_tdx.backtest.cli import portfolio
runner = CliRunner()
result = runner.invoke(portfolio, ["--help"])
assert result.exit_code == 0
assert "--evaluate" in result.output
assert "--wf" in result.output
assert "--auto-fees" in result.output
+55
View File
@@ -215,3 +215,58 @@ class TestStrategyPresets:
for name, grid in STRATEGY_PRESETS.items():
size = math.prod(len(v) for v in grid.values()) if grid else 1
assert size <= 200, f"{name}: 预设网格 {size} 点超上限"
class TestOptimizeAllStrategies:
"""一键寻优所有内置策略."""
def test_ranking_sorted_and_labeled(self) -> None:
"""排名按 total_return 降序,best 是 ranking[0],附中文 label。"""
from easy_tdx.backtest.optimizer import optimize_all_strategies
presets = {
"ma_cross": {"fast": [5, 10], "slow": [20, 30]},
"donchian": {"n": [10, 20]},
}
report = optimize_all_strategies(_make_df(), presets=presets)
assert report["skipped"] == []
assert report["total_grid_points"] == sum(r["grid_points"] for r in report["ranking"])
returns = [r["total_return"] for r in report["ranking"]]
assert returns == sorted(returns, reverse=True)
assert report["best"] == report["ranking"][0]
for r in report["ranking"]:
assert r["strategy_label"]
assert set(r) >= {
"strategy",
"strategy_label",
"params",
"total_return",
"sharpe",
"max_drawdown",
"total_trades",
"win_rate",
"profit_factor",
"grid_points",
}
def test_unregistered_preset_skipped(self) -> None:
"""预设里指向未注册策略的条目应进 skipped,不中断整体寻优。"""
from easy_tdx.backtest.optimizer import optimize_all_strategies
presets = {
"ma_cross": {"fast": [5], "slow": [20]},
"no_such_strat": {"n": [10]},
}
report = optimize_all_strategies(_make_df(), presets=presets)
assert report["skipped"] == ["no_such_strat"]
assert [r["strategy"] for r in report["ranking"]] == ["ma_cross"]
def test_json_native_values(self) -> None:
"""结果应为 JSON 原生类型(可直供 CLI/REST 序列化)。"""
import json
from easy_tdx.backtest.optimizer import optimize_all_strategies
presets = {"ma_cross": {"fast": [5, 10], "slow": [20]}}
report = optimize_all_strategies(_make_df(), presets=presets)
json.dumps(report, allow_nan=False) # NaN/Inf 抛 ValueError