From 00b7d75caa90dcdb529fef77ccb5fc87e830670f Mon Sep 17 00:00:00 2001 From: GitHub Date: Tue, 9 Jun 2026 18:37:58 +0800 Subject: [PATCH] docs: add backtest engine usage manual Co-Authored-By: Claude Opus 4.8 --- docs/backtest_usage.md | 656 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 656 insertions(+) create mode 100644 docs/backtest_usage.md diff --git a/docs/backtest_usage.md b/docs/backtest_usage.md new file mode 100644 index 0000000..3666460 --- /dev/null +++ b/docs/backtest_usage.md @@ -0,0 +1,656 @@ +# easy-tdx 回测引擎使用手册 + +`easy_tdx.backtest` 是一个纯计算层的向量化策略回测引擎,零网络依赖,可完全离线运行。 + +## 目录 + +- [快速开始](#快速开始) +- [编写策略](#编写策略) + - [策略生命周期](#策略生命周期) + - [访问行情数据](#访问行情数据) + - [注册技术指标](#注册技术指标) + - [金叉检测](#金叉检测) + - [生成交易信号](#生成交易信号) +- [引擎配置](#引擎配置) + - [成交价规则](#成交价规则) + - [仓位模式](#仓位模式) + - [费用模型](#费用模型) + - [订单拒绝策略](#订单拒绝策略) +- [获取回测结果](#获取回测结果) + - [绩效指标一览](#绩效指标一览) + - [资金曲线](#资金曲线) + - [交易记录](#交易记录) + - [序列化输出](#序列化输出) +- [CLI 命令行](#cli-命令行) +- [进阶用法](#进阶用法) + - [预计算指标列](#预计算指标列) + - [缠论结果注入](#缠论结果注入) + - [自定义策略文件](#自定义策略文件) +- [完整示例](#完整示例) + +--- + +## 快速开始 + +```python +import pandas as pd +from easy_tdx.backtest import BacktestEngine, Strategy, crossover +from easy_tdx import MyTT + + +# 1. 定义策略 +class DualMAStrategy(Strategy): + def init(self): + self.ma5 = self.I(MyTT.MA, self.data.close, 5) + self.ma20 = self.I(MyTT.MA, self.data.close, 20) + self.cross = crossover(self.ma5, self.ma20) + + def next(self): + if self.cross[self._bar_index]: + self.buy(size=0) # 全仓买入 + elif self.position["size"] > 0: + self.sell(size=0) # 全部卖出 + + +# 2. 准备数据(DataFrame 必须包含 datetime, open, close, high, low 列) +# 通过 TdxClient 获取真实数据: +# from easy_tdx import TdxClient +# client = TdxClient() +# df = client.get_stock_kline("SZ", "000001", period="DAILY", count=500) + +# 3. 运行回测 +engine = BacktestEngine(DualMAStrategy, cash=100000) +result = engine.run(df) + +# 4. 查看结果 +print(f"总收益率: {result.performance['total_return']:.2%}") +print(f"夏普比率: {result.performance['sharpe']:.2f}") +print(f"最大回撤: {result.performance['max_drawdown']:.2%}") +print(f"交易次数: {result.performance['total_trades']}") +``` + +--- + +## 编写策略 + +### 策略生命周期 + +继承 `Strategy` 基类,实现两个方法: + +```python +class MyStrategy(Strategy): + def init(self): + """回测开始前调用一次。注册指标、初始化内部状态。""" + pass + + def next(self): + """每根 K 线调用一次。根据当前行情生成交易信号。""" + pass +``` + +引擎内部执行顺序: + +``` +_bind_data(df) → _call_init() → 逐 bar 调用 _set_bar_index(i) + _call_next() +``` + +### 访问行情数据 + +通过 `self.data` 代理访问 K 线数据,支持相对索引: + +```python +def next(self): + # 当前 bar(索引 0) + price = self.data.close[0] + + # 前一根 bar(索引 -1) + prev_price = self.data.close[-1] + + # 前两根 bar(索引 -2) + prev2 = self.data.close[-2] +``` + +**标准列**:`open`, `close`, `high`, `low`, `vol`, `amount` + +```python +self.data.open[0] # 开盘价 +self.data.close[0] # 收盘价 +self.data.high[0] # 最高价 +self.data.low[0] # 最低价 +self.data.vol[0] # 成交量 +self.data.amount[0] # 成交额 +``` + +**自定义列**:如果 DataFrame 包含额外列(如 `MACD_DIF`),通过属性名直接访问: + +```python +self.data.MACD_DIF[0] # 自动通过 __getattr__ 查找 +``` + +**获取完整数组**:`.raw` 属性返回 numpy 数组,可传入指标函数: + +```python +close_array = self.data.close.raw # numpy ndarray +``` + +### 注册技术指标 + +使用 `self.I()` 在 `init()` 中注册指标。`_SeriesAccessor` 参数会自动解包为 numpy 数组: + +```python +from easy_tdx import MyTT + +def init(self): + # 均线 + self.ma5 = self.I(MyTT.MA, self.data.close, 5) + self.ma20 = self.I(MyTT.MA, self.data.close, 20) + + # MACD + self.dif, self.dea, self.macd = self.I(MyTT.MACD, self.data.close) + + # 布林带 + self.upper, self.mid, self.lower = self.I(MyTT.BOLL, self.data.close, 20) + +def next(self): + # 用索引访问指标值 + if self.ma5[self._bar_index] > self.ma20[self._bar_index]: + self.buy(size=0) +``` + +### 金叉检测 + +`crossover(a, b)` 检测序列 a 从下方穿越 b(金叉): + +```python +from easy_tdx.backtest import crossover + +def init(self): + self.ma5 = self.I(MyTT.MA, self.data.close, 5) + self.ma20 = self.I(MyTT.MA, self.data.close, 20) + self.golden = crossover(self.ma5, self.ma20) # 金叉:ma5 上穿 ma20 + self.death = crossover(self.ma20, self.ma5) # 死叉:ma20 上穿 ma5 + +def next(self): + if self.golden[self._bar_index]: + self.buy(size=0) + if self.death[self._bar_index]: + self.sell(size=0) +``` + +### 生成交易信号 + +在 `next()` 中调用 `self.buy()` 或 `self.sell()`: + +```python +self.buy(size=100) # 买入 100 股 +self.buy(size=0) # 全仓买入(引擎自动计算股数) +self.buy(size=100, price=10.5) # 限价买入 +self.buy(size=100, stop_loss=9.0, take_profit=12.0) # 带止损止盈 + +self.sell(size=100) # 卖出 100 股 +self.sell(size=0) # 全部卖出 +``` + +**参数说明**: + +| 参数 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `size` | float | 0 | 交易数量,0 = 全仓/清仓 | +| `price` | float \| None | None | 限价,None = 市价单 | +| `stop_loss` | float \| None | None | 止损价(预留) | +| `take_profit` | float \| None | None | 止盈价(预留) | + +**查看当前持仓**: + +```python +def next(self): + pos = self.position # {"size": 100.0} + if pos["size"] > 0: + # 当前持有多头 + pass +``` + +--- + +## 引擎配置 + +```python +engine = BacktestEngine( + strategy=MyStrategy, + cash=100000.0, # 初始资金 + commission=0.0003, # 佣金率(万三) + min_commission=5.0, # 最低佣金(元) + stamp_tax=0.001, # 印花税率(千一,仅卖出) + slippage=0.0, # 滑点(每股) + execution="next_open", # 成交价规则 + position_mode="full", # 仓位模式 + reject_policy="reduce", # 拒绝策略 +) +``` + +### 成交价规则 + +| 模式 | 成交价 | 说明 | +|------|--------|------| +| `next_open` | 下一根 K 线开盘价 | **默认**,最贴近实盘 | +| `next_close` | 下一根 K 线收盘价 | 日内策略常用 | +| `this_close` | 当前 K 线收盘价 | ⚠️ 存在**未来函数**风险,引擎会标记 `future_leak_warning` | +| `worst` | 买入取高价 / 卖出取低价 | 保守估计滑点 | +| `best` | 买入取低价 / 卖出取高价 | 乐观估计 | + +信号在 bar N 产生时,`next_*` 模式在 bar N+1 成交,`this_close` 在 bar N 成交。 + +### 仓位模式 + +| 模式 | `buy(size=X)` 行为 | +|------|---------------------| +| `full` | `size=0` 时全仓,按 100 股整手计算;`size>0` 时买入指定股数 | +| `fixed` | 严格按 `size` 买入指定股数 | +| `percent` | `size` 表示总资产的百分比(如 0.5 = 50%),按 100 股整手计算 | + +### 费用模型 + +引擎模拟 A 股费用结构: + +- **佣金**:`max(成交金额 × commission, min_commission)`,双向收取 +- **印花税**:`成交金额 × stamp_tax`,仅卖出时收取 +- **滑点**:`成交股数 × slippage`,双向收取 + +```python +# 免佣回测 +engine = BacktestEngine(MyStrategy, commission=0.0, min_commission=0.0, stamp_tax=0.0) + +# 模拟实际佣金 +engine = BacktestEngine(MyStrategy, commission=0.00025, min_commission=5.0, stamp_tax=0.001) +``` + +### 订单拒绝策略 + +当资金不足(买入)或持仓不足(卖出)时: + +| 策略 | 行为 | +|------|------| +| `reduce` | 减少到可执行的股数,生成实际成交 | +| `skip` | 拒绝整个订单,标记 `rejected=True` | + +--- + +## 获取回测结果 + +```python +result = engine.run(df) +``` + +`result` 是 `BacktestResult` 对象,包含: + +### 绩效指标一览 + +```python +perf = result.performance # dict[str, float] +``` + +| 指标 | Key | 说明 | +|------|-----|------| +| 总收益率 | `total_return` | (期末权益 / 期初资金) - 1 | +| 年化收益率 | `annual_return` | 按年化复利计算 | +| 最大回撤 | `max_drawdown` | 峰值到谷底的最大跌幅比例 | +| 最大回撤持续 | `max_dd_duration` | 最大回撤持续的 bar 数 | +| 夏普比率 | `sharpe` | (日超额收益均值 / 日标准差) × √252 | +| 索提诺比率 | `sortino` | 分母只用负收益标准差 | +| 卡玛比率 | `calmar` | 年化收益 / 最大回撤 | +| 年化波动率 | `volatility` | 日收益率标准差 × √252 | +| 总交易次数 | `total_trades` | 卖出次数(完整闭环) | +| 盈利次数 | `win_trades` | PnL > 0 的卖出 | +| 亏损次数 | `lose_trades` | PnL ≤ 0 的卖出 | +| 被拒绝次数 | `rejected_trades` | 资金/持仓不足被拒绝的总次数 | +| 胜率 | `win_rate` | 盈利次数 / 总交易次数 | +| 盈亏比 | `profit_factor` | 总盈利 / |总亏损| | +| 平均盈利 | `avg_win` | 盈利交易的平均 PnL | +| 平均亏损 | `avg_loss` | 亏损交易的平均 PnL | +| 最大盈利 | `max_win` | 单笔最大盈利 | +| 最大亏损 | `max_loss` | 单笔最大亏损 | +| 平均持仓天数 | `avg_holding_days` | 固定值 5.0(待改进) | + +### 资金曲线 + +```python +equity = result.equity_curve # pd.DataFrame +# 列:datetime, cash, position_value, total, drawdown, drawdown_pct +``` + +| 列 | 说明 | +|----|------| +| `datetime` | 时间 | +| `cash` | 可用现金 | +| `position_value` | 持仓市值 | +| `total` | 总权益 = cash + position_value | +| `drawdown` | 回撤金额 = 峰值 - 当前总权益 | +| `drawdown_pct` | 回撤比例 | + +### 交易记录 + +```python +trades = result.trades # pd.DataFrame +# 列:datetime, direction, size, price, commission, pnl, rejected +``` + +### 持仓快照 + +```python +positions = result.positions # pd.DataFrame +# 列:datetime, size, avg_price, market_value, unrealized_pnl +``` + +### 序列化输出 + +```python +# JSON 字符串 +json_str = result.to_json() + +# Python 字典(DataFrame 转为 records 列表) +data = result.to_dict() + +# 打印概要到标准输出 +result.summary() +``` + +### 配置快照 + +```python +config = result.config +# {"cash": 100000, "commission": 0.0003, "execution": "next_open", +# "position_mode": "full", "reject_policy": "reduce", +# "future_leak_warning": False} +``` + +--- + +## CLI 命令行 + +```bash +# 基本用法 +easy-tdx backtest SZ 000001 --strategy-file my_strategy.py + +# 查看帮助 +easy-tdx backtest --help + +# 指定参数 +easy-tdx backtest SH 600519 \ + --strategy-file ma_cross.py \ + --cash 50000 \ + --commission 0.0003 \ + --execution next_open \ + --period DAILY \ + --count 500 \ + --table + +# 预计算指标(MACD, KDJ 会作为额外列注入 DataFrame) +easy-tdx backtest SZ 000001 \ + --strategy-file macd_strategy.py \ + --indicators MACD,KDJ + +# 输出 JSON(默认) +easy-tdx backtest SZ 000001 --strategy-file my_strategy.py + +# 输出表格 +easy-tdx backtest SZ 000001 --strategy-file my_strategy.py --table +``` + +**CLI 参数**: + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `MARKET` | — | 市场代码:SZ / SH | +| `CODE` | — | 股票代码:如 000001 | +| `--strategy-file` | — | Python 策略文件路径 | +| `--strategy` | — | DSL 表达式(P1,尚未实现) | +| `--cash` | 100000 | 初始资金 | +| `--commission` | 0.0003 | 佣金率 | +| `--execution` | next_open | 成交价规则 | +| `--period` | DAILY | K 线周期 | +| `--adjust` | NONE | 复权方式:NONE / QFQ / HFQ | +| `--count` | 500 | K 线数量 | +| `--indicators` | — | 预计算指标(逗号分隔) | +| `--table` | False | 表格输出 | +| `--output` | json | 输出格式:json / table / csv | + +--- + +## 进阶用法 + +### 预计算指标列 + +策略可使用 DataFrame 中预先计算的指标列。通过 `self.data.列名` 访问: + +```python +from easy_tdx.backtest import BacktestEngine, Strategy +from easy_tdx.indicator import compute_indicators + +class BollingerStrategy(Strategy): + def init(self): + # BOLL_UPPER 已在 DataFrame 中预计算 + self.upper = self.data.BOLL_UPPER + self.lower = self.data.BOLL_LOWER + + def next(self): + if self.data.close[0] < self.lower[0]: + self.buy(size=0) # 跌破下轨买入 + elif self.data.close[0] > self.upper[0]: + self.sell(size=0) # 突破上轨卖出 + +# 预计算指标 +df = compute_indicators(df, ["BOLL"]) +engine = BacktestEngine(BollingerStrategy) +result = engine.run(df) +``` + +### 缠论结果注入 + +v1 提供手动注入接口,策略通过 `self.chanlun` 访问: + +```python +from easy_tdx.backtest import BacktestEngine, Strategy +from easy_tdx.chanlun import ChanlunAnalyser + +class ChanlunStrategy(Strategy): + def init(self): + pass + + def next(self): + cl = self.chanlun + if cl is None: + return + # 使用缠论买卖点 + # mmd_list = cl.get("mmd", []) + # ... + +# 获取缠论结果 +analyser = ChanlunAnalyser("SZ000001", "DAILY") +cl_result = analyser.process_klines(df) + +# 注入引擎 +engine = BacktestEngine(ChanlunStrategy) +result = engine.run(df, chanlun_result=cl_result.to_dict()) +``` + +### 自定义策略文件 + +CLI 的 `--strategy-file` 加载 Python 文件,文件中必须包含一个 `Strategy` 子类: + +```python +# my_strategy.py +from easy_tdx.backtest import Strategy, crossover +from easy_tdx import MyTT + + +class MyStrategy(Strategy): + """双均线策略。""" + def init(self): + self.ma5 = self.I(MyTT.MA, self.data.close, 5) + self.ma20 = self.I(MyTT.MA, self.data.close, 20) + self.cross = crossover(self.ma5, self.ma20) + + def next(self): + if self.cross[self._bar_index]: + self.buy(size=0) + elif self.position["size"] > 0: + self.sell(size=0) +``` + +使用: + +```bash +easy-tdx backtest SZ 000001 --strategy-file my_strategy.py --table +``` + +--- + +## 完整示例 + +### 示例 1:双均线交叉策略 + +```python +"""双均线交叉策略:MA5 上穿 MA20 买入,下穿卖出。""" +import pandas as pd +from easy_tdx.backtest import BacktestEngine, Strategy, crossover +from easy_tdx import MyTT + + +class DualMACross(Strategy): + def init(self): + self.ma5 = self.I(MyTT.MA, self.data.close, 5) + self.ma20 = self.I(MyTT.MA, self.data.close, 20) + self.golden = crossover(self.ma5, self.ma20) + self.death = crossover(self.ma20, self.ma5) + + def next(self): + if self.golden[self._bar_index] and self.position["size"] == 0: + self.buy(size=0) + elif self.death[self._bar_index] and self.position["size"] > 0: + self.sell(size=0) + + +# 构造模拟数据(实际使用 TdxClient 获取) +dates = pd.date_range("2024-01-01", periods=200, freq="D") +import numpy as np +rng = np.random.default_rng(42) +close = 10.0 + np.cumsum(rng.normal(0, 0.2, 200)) + +df = pd.DataFrame({ + "datetime": dates, + "open": close + rng.uniform(-0.1, 0.1, 200), + "close": close, + "high": close + rng.uniform(0, 0.3, 200), + "low": close - rng.uniform(0, 0.3, 200), + "vol": rng.integers(10000, 100000, 200), +}) + +engine = BacktestEngine(DualMACross, cash=100000, commission=0.0003) +result = engine.run(df) + +result.summary() +print(f"\n年化收益: {result.performance['annual_return']:.2%}") +print(f"夏普比率: {result.performance['sharpe']:.2f}") +``` + +### 示例 2:MACD 策略 + 预计算指标 + +```python +"""MACD 策略:DIF 上穿 DEA 买入,下穿卖出。""" +from easy_tdx.backtest import BacktestEngine, Strategy, crossover +from easy_tdx import MyTT + + +class MACDStrategy(Strategy): + def init(self): + dif, dea, macd_hist = self.I(MyTT.MACD, self.data.close) + self.dif = dif + self.dea = dea + self.golden = crossover(dif, dea) + self.death = crossover(dea, dif) + + def next(self): + if self.golden[self._bar_index] and self.position["size"] == 0: + self.buy(size=0) + elif self.death[self._bar_index] and self.position["size"] > 0: + self.sell(size=0) + + +engine = BacktestEngine(MACDStrategy, cash=100000) +result = engine.run(df) # df 包含 OHLCV 数据 +``` + +### 示例 3:布林带突破 + 滑点模拟 + +```python +"""布林带策略:跌破下轨买入,突破上轨卖出,模拟滑点。""" +from easy_tdx.backtest import BacktestEngine, Strategy +from easy_tdx import MyTT + + +class BollingerBreakout(Strategy): + def init(self): + upper, mid, lower = self.I(MyTT.BOLL, self.data.close, 20) + self.upper = upper + self.lower = lower + + def next(self): + cur = self.data.close[0] + if cur <= self.lower[self._bar_index] and self.position["size"] == 0: + self.buy(size=0) + elif cur >= self.upper[self._bar_index] and self.position["size"] > 0: + self.sell(size=0) + + +# 模拟滑点和保守成交价 +engine = BacktestEngine( + BollingerBreakout, + cash=100000, + slippage=0.02, # 每股 2 分钱滑点 + execution="worst", # 保守成交价 + reject_policy="skip", # 资金不足直接跳过 +) +result = engine.run(df) +``` + +### 示例 4:从文件运行 CLI + +```python +# save as rsi_strategy.py +from easy_tdx.backtest import Strategy +from easy_tdx import MyTT + + +class RSIStrategy(Strategy): + """RSI 超卖超买策略。""" + def init(self): + self.rsi = self.I(MyTT.RSI, self.data.close, 14) + + def next(self): + cur_rsi = self.rsi[self._bar_index] + if cur_rsi < 30 and self.position["size"] == 0: + self.buy(size=0) + elif cur_rsi > 70 and self.position["size"] > 0: + self.sell(size=0) +``` + +```bash +easy-tdx backtest SZ 000001 \ + --strategy-file rsi_strategy.py \ + --cash 200000 \ + --execution next_open \ + --count 1000 \ + --adjust QFQ \ + --table +``` + +--- + +## 注意事项 + +1. **DataFrame 格式要求**:必须包含 `datetime`, `open`, `close`, `high`, `low` 列。`vol`/`amount` 为可选但推荐。 +2. **成交时机**:默认 `next_open` 模式下,信号产生后需等待下一根 K 线才能成交。如果信号在最后一根 K 线产生,则无法成交。 +3. **整手交易**:A 股按 100 股整手交易。全仓模式会自动向下取整到 100 的倍数。 +4. **做空限制**:v1 不支持做空,卖出数量不能超过当前持仓。 +5. **未来函数警告**:使用 `this_close` 模式时,结果中的 `config.future_leak_warning` 会标记为 `True`。 +6. **多笔同 bar 交易**:引擎支持同一根 K 线上产生多笔交易(如分批建仓),按顺序依次撮合。