Files
easy_tdx_max/docs/backtest_usage.md
T
GitHub 155328df8b release: v1.16.2 — 三轮审计质量加固(B6.9→A7.9)
经三轮代码审计后的综合质量加固版本,覆盖协议核心层、数据正确性、
错误处理、测试真实度与可维护性。761 单测全绿(+58),ruff/mypy 全过。

主要修复:
- 离线 .day 写入原子化(fsync + _repair_tail + 读取校验,CQS 守住)
- 回测止损前视偏差(延迟下一根开盘 + 跳空保护)
- VWAP 权重索引 / bar_time fail-fast / 绩效除零保护
- 闭包绑定 / 路径穿越 / naive datetime 跨时区 / ruff UP038

重构:
- 抽 AsyncHeartbeatMixin 收敛 4 处心跳副本(12→1)
- 统一 _RETRY_DELAYS 退避序列 / scanner 失败可观测性

新增 5 个测试文件 + 公共 API 类型契约,CI 加 Windows 矩阵 +
trusted publishing 签名 + 锁文件。

详见 CHANGELOG.md
2026-07-02 03:37:37 +08:00

662 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | 止损价。当根 bar 的 low 触及止损价时触发平仓信号 |
| `take_profit` | float \| None | None | 止盈价。当根 bar 的 high 触及止盈价时触发平仓信号 |
> **止损/止盈成交时点**:SL/TP 信号触发后,**延迟到下一根 bar 开盘成交**(与普通
> 策略信号一致),而非在信号当根以触发价成交。若下一根跳空,取对持仓者更不利的实际
> 开盘价(卖出取 `min(下一根开盘, 触发价)`)。这避免了"假设能在止损价精确成交"的
> 前视偏差,回测结果更贴近真实滑点与跳空场景。
**查看当前持仓**
```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 线上产生多笔交易(如分批建仓),按顺序依次撮合。