diff --git a/CHANGELOG.md b/CHANGELOG.md index 7355dba..6e93a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 的日期」锚定最近交易日,个股与指数全场景有数。 diff --git a/README.md b/README.md index db85fe2..1931d66 100644 --- a/README.md +++ b/README.md @@ -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 ``` 输出示例: diff --git a/docs/backtest_usage.md b/docs/backtest_usage.md index eaa0f9e..dca7046 100644 --- a/docs/backtest_usage.md +++ b/docs/backtest_usage.md @@ -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 +``` --- diff --git a/examples/20_cli/cli_examples.sh b/examples/20_cli/cli_examples.sh index 011c959..90cb21b 100644 --- a/examples/20_cli/cli_examples.sh +++ b/examples/20_cli/cli_examples.sh @@ -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 diff --git a/src/easy_tdx/backtest/cli.py b/src/easy_tdx/backtest/cli.py index 7f3b2bf..023bfaf 100644 --- a/src/easy_tdx/backtest/cli.py +++ b/src/easy_tdx/backtest/cli.py @@ -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)") diff --git a/src/easy_tdx/backtest/optimizer.py b/src/easy_tdx/backtest/optimizer.py index f40c48d..1e2a70a 100644 --- a/src/easy_tdx/backtest/optimizer.py +++ b/src/easy_tdx/backtest/optimizer.py @@ -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)。 diff --git a/src/easy_tdx/cli/__init__.py b/src/easy_tdx/cli/__init__.py index ccb7206..4aeb0bb 100644 --- a/src/easy_tdx/cli/__init__.py +++ b/src/easy_tdx/cli/__init__.py @@ -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) diff --git a/tests/unit/test_backtest_cli.py b/tests/unit/test_backtest_cli.py index 711bbf3..bf0c921 100644 --- a/tests/unit/test_backtest_cli.py +++ b/tests/unit/test_backtest_cli.py @@ -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 diff --git a/tests/unit/test_optimizer.py b/tests/unit/test_optimizer.py index c1a4d0f..d7a2cdc 100644 --- a/tests/unit/test_optimizer.py +++ b/tests/unit/test_optimizer.py @@ -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