mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 13:24:18 +08:00
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:
@@ -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 的日期」锚定最近交易日,个股与指数全场景有数。
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
输出示例:
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:6(36 点)
|
||||
# 说明: 快线上穿慢线买入,快线下穿慢线卖出。最经典的趋势跟随策略。
|
||||
#
|
||||
# macd — MACD 金叉
|
||||
# 参数: short=12, long=26, signal=9
|
||||
# 预设网格: short:4 × long:4(16 点)
|
||||
# ...
|
||||
# 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_cross(8 个有效网格点)===
|
||||
#
|
||||
# 排名 参数 总收益率 夏普 最大回撤 交易 胜率
|
||||
# 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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user