Refactor code structure for improved readability and maintainability

This commit is contained in:
tigerenwork 2026-05-31 13:49:54 +08:00
parent 31f8695d56
commit 9546279fcf
8 changed files with 4267 additions and 0 deletions

509
doc_01_data_pipeline.md Normal file
View File

@ -0,0 +1,509 @@
# 量化交易数据管道详解
## `quant_data_pipeline_demo.py` 学习文档
> **目标读者**:零基础量化入门者
> **配套文件**`quant_data_pipeline_demo.py`
> **系列位置**:第 1 篇 — 数据准备篇
---
## 目录
1. [为什么数据准备如此重要?](#1-为什么数据准备如此重要)
2. [主题一:价格复权 (Price Adjustment)](#2-主题一价格复权-price-adjustment)
3. [主题二:收益率计算 (Return Calculation)](#3-主题二收益率计算-return-calculation)
4. [主题三:多标的面板与缺失值处理 (Panel & Missing Values)](#4-主题三多标的面板与缺失值处理)
5. [主题四:异常值检测与处理 (Outlier Detection & Treatment)](#5-主题四异常值检测与处理)
6. [主题四B涨跌停标记 (Circuit Breaker Flags)](#6-主题四b涨跌停标记-circuit-breaker-flags)
7. [主题五:交易日历与跨市场对齐 (Trading Calendar)](#7-主题五交易日历与跨市场对齐)
8. [主题六:端到端数据管道类 (DataPipeline Class)](#8-主题六端到端数据管道类-datapipeline-class)
9. [常见错误与避坑指南](#9-常见错误与避坑指南)
10. [术语速查表](#10-术语速查表)
---
## 1. 为什么数据准备如此重要?
> **"Garbage in, garbage out."(垃圾进,垃圾出。)**
在量化交易 (Quantitative Trading) 中,策略再好,如果输入的数据有问题,回测结果就毫无意义,甚至会误导你投入真实资金。
**一个生动的例子:**
假设你手里有一支股票的历史价格数据,在某一天价格从 10 元突然跳到 5 元。
如果你直接计算收益率,会得到 `-50%` 的单日大跌。
但实际上,那一天股票进行了 **1:2 拆股Stock Split**,每股变成两股,价格减半是完全正常的市场行为,真实的收益率是 **0%**
如果你的算法把这个 `-50%` 当成真实的大跌信号,就会产生错误的交易决策。
**数据准备解决的核心问题:**
| 问题 | 来源 | 后果(如果不处理)|
|------|------|------------------|
| 价格跳跃(非真实波动)| 拆股、分红 | 虚假的极端收益率,扭曲指标计算 |
| 缺失值 (Missing Values) | 停牌、数据源错误 | NaN 传播导致指标全部失效 |
| 异常值 (Outliers) | 数据错误、极端行情 | 拉偏统计量,产生虚假信号 |
| 涨跌停 (Price Limits) | A 股特有规则 | 误以为可以成交,策略无法执行 |
| 日历错位 (Calendar Mismatch) | 跨市场不同假日 | 计算相关性时出现错位 |
---
## 2. 主题一:价格复权 (Price Adjustment)
### 2.1 什么是"公司行为"
**公司行为 (Corporate Actions)** 是公司自愿或法定的资本结构变化,会导致股价出现非市场原因的人为跳跃。最常见的两种:
**① 股票拆分 (Stock Split)**
- 一股变多股,价格等比下降
- 例1:2 拆股 → 原来 1 股 20 元 → 变成 2 股各 10 元
- 持有人总市值不变,但价格图上会出现 `-50%` 的"假下跌"
**② 现金分红 (Cash Dividend)**
- 公司将利润的一部分以现金形式分配给股东
- 股价在除息日 (Ex-Dividend Date) 会自然下降分红金额
- 例:每股分红 0.5 元,股价从 20 元跌到 19.5 元(这是"假跌"
### 2.2 三种价格序列
代码生成并演示了三种价格序列:
```
未复权价格 (Unadjusted Price)
↓ 拆股/分红后,历史价格出现人为跳跃
↓ 不能直接用于计算收益率
前复权价格 (Forward-Adjusted / Backward-Adjusted Price)
↓ 将历史价格按复权因子折算到"当前价格水平"
↓ 连续可微,适合计算技术指标
后复权价格 (Backward-Adjusted Price)
↓ 以最早的价格为基准,向后调整
↓ 保留了历史价格的"真实感",适合长期价值分析
```
**形象理解:**
想象你记录了一个朋友的身高,但 TA 第 3 年做了骨骼手术增高了 5 厘米。
- **未复权**:第 1-2 年记录真实数据,第 3 年突然跳高 5 厘米
- **前复权**:把第 1-2 年的数据都加 5 厘米,保持最新身高是真实的
- **后复权**:第 3 年以后的数据都减 5 厘米,保持早期身高是真实的
### 2.3 关键代码解析
```python
def forward_adjust(unadj_prices, adj_factor):
"""前复权:以最新价格为基准,向前调整历史价格"""
# adj_factor / adj_factor.iloc[-1]
# → 将复权因子标准化为"相对于最新时刻的倍数"
normalized_factor = adj_factor / adj_factor.iloc[-1]
return unadj_prices * normalized_factor
```
**复权因子 (Adjustment Factor)** 是一个随时间变化的乘数,记录了从最初到当前发生的所有拆股和分红的累积影响。
### 2.4 实践建议
| 场景 | 推荐价格类型 |
|------|-------------|
| 计算历史收益率、构建技术指标 | **前复权** (Forward-Adjusted) |
| 研究历史上的真实交易价格 | **后复权** (Backward-Adjusted) |
| 展示给非专业用户的图表 | **未复权** (Unadjusted),加注说明 |
---
## 3. 主题二:收益率计算 (Return Calculation)
### 3.1 两种收益率的公式
**简单收益率 (Simple Return)**
$$r_t = \frac{P_t - P_{t-1}}{P_{t-1}} = \frac{P_t}{P_{t-1}} - 1$$
用代码表示:`prices.pct_change()`
**对数收益率 (Log Return / Continuously Compounded Return)**
$$r_t^{log} = \ln\left(\frac{P_t}{P_{t-1}}\right)$$
用代码表示:`np.log(prices / prices.shift(1))`
### 3.2 为什么量化研究更偏爱对数收益率?
**理由一:时间可加性 (Time Additivity)**
对数收益率可以直接相加得到总收益率:
$$r_{总}^{log} = r_1^{log} + r_2^{log} + \cdots + r_n^{log}$$
简单收益率必须连乘(而不是相加):
$$1 + r_{总} = (1 + r_1) \times (1 + r_2) \times \cdots \times (1 + r_n)$$
**理由二:对称性更好**
- 价格涨 100%(翻倍),简单收益率 = +100%
- 价格跌 50%(腰斩),简单收益率 = -50%
- 但两者其实是反向对称操作!用对数收益率:涨 100% ≈ +69.3%,跌 50% ≈ -69.3%,完美对称
**理由三:统计性质更好**
对数收益率更接近正态分布 (Normal Distribution),更适合统计建模。
### 3.3 什么时候用哪种?
| 用途 | 推荐 |
|------|------|
| 构建技术指标、计算波动率 | 对数收益率 |
| 计算多资产组合收益率 | 简单收益率(加权平均在简单收益率下才正确)|
| 日内高频交易 | 两者差异极小,均可 |
> ⚠️ **重要警告**:绝对不要在同一个计算中混用两种收益率!
---
## 4. 主题三:多标的面板与缺失值处理
### 4.1 什么是数据面板 (Panel Data)
**面板数据**是一张二维表格,行是时间(每个交易日),列是不同的股票(或其他资产):
```
日期 Stock_A Stock_B Stock_C
2022-01-03 100.5 50.2 NaN
2022-01-04 101.2 NaN 80.3
2022-01-05 NaN 51.0 81.0
...
```
`NaN` 就是缺失值,表示"这一天没有数据"。
### 4.2 缺失值的两大来源
**① 停牌 (Trading Suspension)**
- 中国 A 股常见:某只股票因重大事项公告临时暂停交易
- 可能持续数天到数月
- 停牌期间通常用"前值填充"
**② 数据源错误**
- 数据提供商漏报、乱码、延迟上报
- 通常是随机的单日缺失
### 4.3 四种处理方法对比
代码中演示了四种方法,以下是直观对比:
| 方法 | 代码 | 适用场景 | 缺点 |
|------|------|----------|------|
| **向前填充 (ffill)** | `prices.ffill()` | 停牌(假设价格不变)| 长时间停牌后恢复会有虚假零收益 |
| **线性插值 (linear)** | `prices.interpolate()` | 短期随机缺失 | 插值出"未来数据",可能引入前视偏差 |
| **有限填充 (ffill+limit)** | `prices.ffill(limit=5)` | 最多允许填充 5 天 | 超过限制仍会有 NaN |
| **直接删除 (drop)** | `prices.dropna()` | 整行/整列数据质量差 | 丢失样本,影响后续计算 |
### 4.4 前视偏差警告 ⚠️
```python
# ❌ 错误示范bfill 会用"未来价格"填充"过去缺失"
clean_prices = price_panel.bfill() # 危险!引入前视偏差!
# ✅ 正确做法:只向前看,不向后看
clean_prices = price_panel.ffill()
clean_prices = clean_prices.dropna(how='any') # 删掉最开头还剩的 NaN
```
**前视偏差 (Lookahead Bias / Look-Ahead Bias)**:在回测时,不小心使用了"当时还不知道的未来数据",导致回测结果虚高,实盘中无法复现。这是回测中最常见也最危险的错误之一。
### 4.5 缺失值超过多少就应该排除该标的?
代码中使用的规则:`缺失比例 > 10%` → 排除该标的
这是经验阈值,具体情况具体分析:
- 研究期只有 1 年250 天):缺失 > 25 天就该考虑排除
- 研究期有 5 年1250 天):缺失 > 125 天才排除(同比例)
---
## 5. 主题四:异常值检测与处理
### 5.1 什么是异常值?
**异常值 (Outlier)** 是远离其他数据点的极端值,可能来自:
- 数据错误(数据库录入错误、单位错误)
- 真实极端行情(市场崩盘、涨停首日)
- 算法错误
对于收益率数据,典型的异常值是单日涨跌超过 30%(非 A 股市场)的数据。
### 5.2 两种检测方法
#### Z-score 方法(标准分数法)
$$Z = \frac{x - \bar{x}}{\sigma}$$
- 计算每个值偏离均值多少个标准差
- `|Z| > 3` 视为异常值
**通俗理解**:班级考试平均 70 分,标准差 10 分。
你考了 100 分Z = (100-70)/10 = 3属于极端高分。
**缺点**:均值 $\bar{x}$ 和标准差 $\sigma$ 本身会被异常值"污染",所以 Z-score 对异常值不够稳健 (Not Robust)。
#### MAD 方法中位数绝对偏差Median Absolute Deviation
$$\text{MAD} = \text{median}(|x_i - \text{median}(x)|)$$
$$\text{Modified Z} = \frac{0.6745 \times (x - \text{median}(x))}{\text{MAD}}$$
- 用**中位数**代替均值,对极端值天然免疫
- 量化研究中更常用
**通俗理解**Z-score 用"平均数"衡量偏差异常值会把平均数拉偏进而影响判断。MAD 用"中位数"衡量,中位数不受极端值影响。
### 5.3 Winsorize缩尾处理
**Winsorize** 不是删除异常值,而是把它们"拉回"到合理范围内:
```python
def winsorize(series, lower_pct=0.01, upper_pct=0.99):
lower = series.quantile(0.01) # 1% 分位数
upper = series.quantile(0.99) # 99% 分位数
return series.clip(lower=lower, upper=upper)
# 低于 1% 分位数的值 → 拉回到 1% 分位数
# 高于 99% 分位数的值 → 拉回到 99% 分位数
```
**优点**:保留所有数据量,只是限制极端值的影响范围。这在构建因子时是标准操作。
### 5.4 Q-Q 图 (Quantile-Quantile Plot)
Q-Q 图用于直观判断数据是否符合正态分布:
- **点在对角线上**:数据是正态分布
- **两端翘起(像字母 S 或笑脸)**:分布有**厚尾 (Fat Tails)**
金融收益率几乎**总是**有厚尾。这意味着极端事件(大涨大跌)的发生频率远高于正态分布的预测,所以不能天真地假设收益率服从正态分布。
---
## 6. 主题四B涨跌停标记 (Circuit Breaker Flags)
### 6.1 中国 A 股特有规则
**涨跌停板制度 (Price Limit System)**
- 普通股票:单日最大涨幅 **+10%**,最大跌幅 **-10%**
- ST 股票(风险警示股):限制为 **±5%**
这不是数据错误!这是真实发生的市场数据。
### 6.2 为什么需要标记?
当股票涨停或跌停时:
- ✅ 收盘价是真实的
- ❌ 但这只股票**很可能全天无法成交**(买盘/卖盘全部封死)
**后果**:如果你的策略在涨停日产生"卖出"信号,实际上根本卖不掉;跌停日产生"买入"信号,根本买不到。
这叫**流动性陷阱 (Liquidity Trap)**。
### 6.3 代码的处理方式
代码**只标记,不删除**
```python
# 返回:+1 = 涨停,-1 = 跌停0 = 正常
def flag_price_limits(prices, limit_pct=0.10):
...
```
策略代码可以根据这个标记自行决定:
- 跳过涨跌停日的信号(最常见)
- 降低该日的信号权重
- 完全排除该日的统计计算
### 6.4 幸存者偏差警告 ⚠️
> **幸存者偏差 (Survivorship Bias)**:数据集只包含"活到现在"的股票,而不包含已退市的股票。这会导致回测收益率虚高,因为你实际上只回测了"最后赢家"。
> 解决方案:使用包含历史退市股票的完整数据集(通常需要付费数据源)。
---
## 7. 主题五:交易日历与跨市场对齐
### 7.1 问题的来源
不同国家的市场有不同的节假日:
- A 股有春节7 天以上假期)
- 美股没有春节,但有感恩节、独立日等
- 港股假期介于两者之间
当你想计算"A 股与美股的相关性"时,两个市场的交易日不完全重合,怎么对齐是一个实际问题。
### 7.2 两种对齐策略
**① 取交集 (Intersection)**
只保留两个市场都开盘的日期:
```python
common_days = us_calendar.intersection(cn_calendar)
```
- 🟢 适合:计算相关性、回归分析、信号对比
- 🔴 缺点:损失了单一市场的交易日数据
**② 向前填充 (Forward Fill)**
对于某市场休市的日期,用上一个交易日的数据填充:
```python
aligned = prices.reindex(combined_index).ffill()
```
- 🟢 适合:持续计算净值曲线、组合市值统计
- 🔴 缺点:引入大量"人工零收益日",会低估波动率,高估夏普比率
### 7.3 生产环境建议
代码中使用了手写的简化假日列表,实际生产中推荐使用专业库:
```python
# 推荐库(需额外安装)
pip install exchange_calendars
pip install pandas_market_calendars
```
---
## 8. 主题六:端到端数据管道类 (DataPipeline Class)
### 8.1 为什么要封装成类?
把以上所有步骤封装成一个 `DataPipeline` 类,好处是:
1. **可复用**:每次只需一行代码 `pipeline.run()` 完成所有清洗
2. **可配置**:通过参数调整阈值,不修改核心逻辑
3. **有记录**:自动生成数据质量报告,便于审计
4. **防错**:步骤固定,不会因顺序出错导致问题
### 8.2 管道的 6 个步骤
```
原始价格面板 (Raw Price Panel)
▼ Step 1: 过滤缺失率 > 10% 的标的
│ (Filter stocks with > 10% missing data)
▼ Step 2: ffill + bfill 填充剩余缺失值
│ (Forward + Backward fill remaining NaN)
▼ Step 3: 计算日收益率
│ (Compute daily returns)
▼ Step 4: MAD 异常值检测 + Winsorize 处理
│ (Detect outliers with MAD, Winsorize at 1%/99%)
▼ Step 5: 生成数据质量报告
│ (Generate quality report: mean, vol, skew, kurtosis)
干净的收益率面板 (Clean Returns Panel)
```
### 8.3 使用示例
```python
from pipeline import DataPipeline # 或直接在 demo 文件末尾运行
pipeline = DataPipeline(
price_panel,
max_missing_pct=0.15, # 缺失率阈值 15%
winsorize_pct=(0.01, 0.99) # 缩尾范围 1%~99%
)
clean_returns = pipeline.run()
```
### 8.4 数据质量报告解读
管道运行后会打印各标的的统计表,关键指标含义:
| 指标 | 含义 | 健康范围(日频)|
|------|------|----------------|
| 日均收益率 (bp) | 平均每天的收益以基点表示1bp = 0.01%| -5 ~ +5 bp |
| 日波动率 (%) | 每天收益率的标准差 | 0.5% ~ 3% |
| 年化收益率 (%) | 日均收益率 × 252 | -30% ~ +50% |
| 年化波动率 (%) | 日波动率 × √252 | 10% ~ 60% |
| 偏度 (Skewness) | 分布左右对称性(负值=左偏=有重大下跌)| -1 ~ +1 |
| 峰度 (Kurtosis) | 尾部厚度(>0 代表比正态分布更厚尾)| 金融数据通常 > 3 |
---
## 9. 常见错误与避坑指南
### ❌ 错误 1忘记复权就直接计算收益率
```python
# 错误:用未复权价格直接计算
returns = unadj_prices.pct_change() # 包含拆股/分红的虚假跳跃!
# 正确
returns = fwd_adj_prices.pct_change()
```
### ❌ 错误 2用 bfill 填充缺失值
```python
# 错误bfill 用未来数据填补过去,引入前视偏差
clean = prices.bfill() # 危险!
# 正确:只用 ffill
clean = prices.ffill()
```
### ❌ 错误 3异常值处理后忘记重新计算收益率
```python
# 错误:先计算收益率,再检测,再删除某些日期
returns = prices.pct_change()
returns = returns[~outlier_mask] # 删掉某些日期后,相邻的收益率跨越了多天!
# 正确:要么 Winsorize不删日期要么先处理价格再算收益率
returns_clean = winsorize(returns) # 保持日期连续性
```
### ❌ 错误 4跨市场计算时忘记对齐日历
```python
# 错误:直接计算相关性
corr = returns_us.corr(returns_cn) # 如果索引日期不同pandas 会产生大量 NaN
# 正确:先对齐
common_idx = returns_us.index.intersection(returns_cn.index)
corr = returns_us[common_idx].corr(returns_cn[common_idx])
```
---
## 10. 术语速查表
| 中文 | English | 简要说明 |
|------|---------|----------|
| 量化交易 | Quantitative Trading | 用数学模型和程序自动化交易 |
| 复权 | Price Adjustment | 消除拆股/分红对价格的人为影响 |
| 前复权 | Forward Adjustment | 历史价格折算到当前水平 |
| 后复权 | Backward Adjustment | 当前价格折算到历史水平 |
| 复权因子 | Adjustment Factor | 价格调整的乘数 |
| 拆股 | Stock Split | 一股变多股,价格等比下降 |
| 除息日 | Ex-Dividend Date | 分红的价格自然下降日 |
| 简单收益率 | Simple Return | (P_t - P_{t-1}) / P_{t-1} |
| 对数收益率 | Log Return | ln(P_t / P_{t-1}) |
| 时间可加性 | Time Additivity | 对数收益率可直接相加的性质 |
| 面板数据 | Panel Data | 多标的 × 多时间的二维数据表 |
| 停牌 | Trading Suspension | 股票暂停交易 |
| 缺失值 | Missing Value / NaN | 数据中的空白 |
| 向前填充 | Forward Fill (ffill) | 用前一个有效值填充 NaN |
| 异常值 | Outlier | 远离正常范围的极端值 |
| 标准分数 | Z-score | 偏离均值的标准差倍数 |
| 中位数绝对偏差 | MAD (Median Absolute Deviation) | 更稳健的异常值检测方法 |
| 缩尾处理 | Winsorize | 将极端值截断到分位数边界 |
| 前视偏差 | Lookahead Bias | 回测中使用未来数据的错误 |
| 幸存者偏差 | Survivorship Bias | 数据集只包含"活下来"的标的 |
| 涨跌停 | Price Limit / Circuit Breaker | 单日涨跌幅上限A 股 ±10%|
| 厚尾 | Fat Tails / Leptokurtosis | 极端事件比正态分布更频繁 |
| 偏度 | Skewness | 分布的左右不对称程度 |
| 峰度 | Kurtosis | 分布尾部的厚度 |
| 交易日历 | Trading Calendar | 某市场的所有交易日集合 |
| 流动性 | Liquidity | 资产在市场上被买卖的难易程度 |
---
*下一篇:[策略开发与向量化回测](doc_02_strategy_backtest.md)*

628
doc_02_strategy_backtest.md Normal file
View File

@ -0,0 +1,628 @@
# 量化策略开发与向量化回测详解
## `quant_strategy_backtest_demo.py` 学习文档
> **目标读者**:零基础量化入门者
> **配套文件**`quant_strategy_backtest_demo.py`
> **前置知识**:建议先阅读 `doc_01_data_pipeline.md`
> **系列位置**:第 2 篇 — 策略开发与回测篇
---
## 目录
1. [本篇概览](#1-本篇概览)
2. [合成价格数据:几何布朗运动](#2-合成价格数据几何布朗运动-geometric-brownian-motion)
3. [技术指标详解](#3-技术指标详解-technical-indicators)
- [3.1 SMA 简单移动平均线](#31-sma-简单移动平均线-simple-moving-average)
- [3.2 EMA 指数移动平均线](#32-ema-指数移动平均线-exponential-moving-average)
- [3.3 RSI 相对强弱指数](#33-rsi-相对强弱指数-relative-strength-index)
- [3.4 MACD 指数平滑异同移动平均线](#34-macd-指数平滑异同移动平均线)
- [3.5 布林带 Bollinger Bands](#35-布林带-bollinger-bands)
4. [策略 A双均线金叉/死叉](#4-策略-a双均线金叉死叉-ma-crossover)
5. [策略 BRSI 均值回归](#5-策略-brsi-均值回归-rsi-mean-reversion)
6. [向量化回测引擎](#6-向量化回测引擎-vectorized-backtest-engine)
7. [绩效指标详解](#7-绩效指标详解-performance-metrics)
8. [滚动前向验证与参数优化](#8-滚动前向验证与参数优化-walk-forward-validation)
9. [收益率分布分析](#9-收益率分布分析-return-distribution-analysis)
10. [术语速查表](#10-术语速查表)
---
## 1. 本篇概览
这篇文档对应量化交易的第二个核心环节:**"有了干净的数据,怎么用它来赚钱?"**
整个流程可以归纳为:
```
干净的价格数据
计算技术指标均线、RSI、MACD、布林带
制定交易规则 → 生成交易信号(买/卖/空仓)
向量化回测引擎(模拟历史交易,扣除成本)
计算绩效指标(夏普、最大回撤、胜率…)
前向验证(防止过拟合)→ 判断策略是否值得继续研究
```
**两种策略类型**,代码各实现一种:
| 类型 | 代表指标 | 逻辑 | 适合市场环境 |
|------|----------|------|-------------|
| **趋势跟随 (Trend Following)** | 移动均线、MACD | 跟着趋势走,买涨跟涨 | 有明显趋势的牛市/熊市 |
| **均值回归 (Mean Reversion)** | RSI、布林带 | 逆势操作,跌多了买、涨多了卖 | 震荡盘整市场 |
---
## 2. 合成价格数据:几何布朗运动 (Geometric Brownian Motion)
### 2.1 为什么用模拟数据?
- 真实数据需要付费数据源,这个 Demo 用合成数据让所有人都能直接运行
- 合成数据有"已知"的统计特性(我们自己设定的 μ 和 σ),便于验证代码正确性
- 通过调整参数可以模拟不同市场环境(牛市、熊市、震荡市)
### 2.2 GBM 公式直观理解
**几何布朗运动 (GBM)** 是描述股价随机游走的经典数学模型也是期权定价公式Black-Scholes的基础。
离散形式(代码实现的):
$$P_t = P_{t-1} \times e^{\left(\mu - \frac{\sigma^2}{2}\right) \cdot \Delta t + \sigma \cdot \sqrt{\Delta t} \cdot \varepsilon_t}$$
用大白话解释这个公式:
- $P_t$:今天的价格
- $P_{t-1}$:昨天的价格
- $\mu$mu**年化漂移率 (Annual Drift)**,相当于股票的"长期趋势倾斜度"。$\mu = 0.10$ 表示每年平均上涨 10%
- $\sigma$sigma**年化波动率 (Annual Volatility)**,表示价格的"抖动剧烈程度"。$\sigma = 0.25$ 表示年化波动 25%
- $\Delta t = 1/252$:一个交易日是一年的 1/252
- $\varepsilon_t \sim N(0,1)$:每天的随机冲击,从标准正态分布中随机抽取
**直觉**:每天的价格变化 = 确定性漂移(趋势)+ 随机波动(噪声)
```python
# 代码中的实现
dt = 1.0 / 252
epsilon = np.random.randn(n_days) # 随机冲击
log_returns = (mu - 0.5 * sigma**2) * dt + sigma * np.sqrt(dt) * epsilon
prices = s0 * np.exp(np.cumsum(log_returns)) # 累积乘积 → 价格路径
```
---
## 3. 技术指标详解 (Technical Indicators)
**技术分析 (Technical Analysis)** 的核心假设:过去的价格和成交量模式包含了预测未来走势的信息。技术指标就是从原始价格数据中提炼这些模式的数学工具。
> ⚠️ **注意**:技术分析在学术界存在争议,有效市场假说 (Efficient Market Hypothesis) 认为价格已经反映了所有信息,技术分析无法持续获得超额收益。但在实践中,许多量化基金仍然将技术指标作为信号的一部分。
### 3.1 SMA 简单移动平均线 (Simple Moving Average)
**公式**
$$\text{SMA}_n(t) = \frac{P_t + P_{t-1} + \cdots + P_{t-n+1}}{n}$$
**直觉**:取最近 n 天收盘价的算术平均数,形成一条平滑的"趋势线"。
**关键参数 n 的选择**
| 窗口 n | 代号 | 特性 |
|--------|------|------|
| 5、10 天 | 短期 (Short-term) | 贴近价格,反应快,但"噪声"多 |
| 20、30 天 | 中期 (Medium-term) | 月线级别的趋势 |
| 60、120 天 | 长期 (Long-term) | 季度级别的主要趋势,滞后但稳定 |
| 250 天 | 年线 (Annual) | 区分牛熊市的重要参考线 |
**代码**
```python
def sma(prices, window):
return prices.rolling(window=window).mean()
# rolling(window) = 创建一个滑动窗口
# .mean() = 对窗口内的值取平均
```
**前 n-1 天为什么没有值?**
计算 20 日均线需要 20 天数据,第 1-19 天的均线值是 `NaN`(数据不够)。第 20 天才有第一个有效均线值。
### 3.2 EMA 指数移动平均线 (Exponential Moving Average)
**公式**
$$\text{EMA}_t = \alpha \cdot P_t + (1 - \alpha) \cdot \text{EMA}_{t-1}, \quad \alpha = \frac{2}{n+1}$$
**与 SMA 的核心区别**SMA 对窗口内所有日期权重相等EMA 给**近期数据更高权重**,越新的数据越重要。
**形象比喻**
SMA 像"民主投票"——每一天的发言权相等;
EMA 像"资历制"——越近的日期说话越算数。
**结果**EMA 比 SMA 对最近的价格变化反应更快,但也更"神经质"(更多小幅震动)。
```python
def ema(prices, span):
return prices.ewm(span=span, adjust=False).mean()
# ewm = exponentially weighted moving (指数加权移动)
# span 对应公式中的 nα = 2/(span+1)
```
### 3.3 RSI 相对强弱指数 (Relative Strength Index)
**RSI 解决的问题**:量化"最近一段时间,涨的力量和跌的力量哪个更强?"
**计算步骤**(以 14 天为例):
1. 计算每天的涨跌:`delta = close.diff()`
2. 上涨天的涨幅 (Gain)`delta > 0` 的部分
3. 下跌天的跌幅 (Loss)`delta < 0` 的部分取正值
4. 用指数平均分别平滑 Gain 和 Loss`avg_gain`、`avg_loss`
5. 计算相对强弱 RS = avg_gain / avg_loss
6. 转换:`RSI = 100 - 100 / (1 + RS)`
**结果范围**RSI 永远在 0 到 100 之间。
| RSI 值 | 解读 |
|--------|------|
| > 70 | **超买 (Overbought)**:涨势过强,可能即将回调 |
| 50~70 | 偏强,多头占优 |
| 50 | **中性 (Neutral)**:多空势均力敌 |
| 30~50 | 偏弱,空头占优 |
| < 30 | **超卖 (Oversold)**跌势过强可能即将反弹 |
**形象比喻**RSI 就像一个"疲劳度量表"。跑得太快(涨太猛)= 超买,该休息了;跌得太惨 = 超卖,该反弹了。
```python
def rsi(prices, window=14):
delta = prices.diff()
gain = delta.clip(lower=0) # 只保留正值(涨的部分)
loss = -delta.clip(upper=0) # 只保留负值并取正(跌的部分)
avg_gain = gain.ewm(alpha=1.0/window, adjust=False).mean()
avg_loss = loss.ewm(alpha=1.0/window, adjust=False).mean()
rs = avg_gain / avg_loss # 相对强弱
return 100 - (100 / (1 + rs)) # 映射到 0~100
```
### 3.4 MACD 指数平滑异同移动平均线
**MACD 解决的问题**:用两条不同速度的 EMA 的"差距"来衡量趋势动能的变化。
**三条线**
| 名称 | 计算 | 含义 |
|------|------|------|
| **MACD 线 (DIF)** | EMA(12) - EMA(26) | 快线(短期)减慢线(长期),反映近期动能 |
| **信号线 (DEA)** | EMA(9) of MACD 线 | MACD 线的 9 日平均,平滑后的趋势 |
| **柱状图 (Histogram)** | MACD 线 - 信号线 | 两线之差,放大了趋势变化 |
**交叉信号**
| 信号 | 含义 |
|------|------|
| **金叉 (Golden Cross)**MACD 线上穿信号线 | 买入信号,趋势转强 |
| **死叉 (Death Cross)**MACD 线下穿信号线 | 卖出信号,趋势转弱 |
| 柱状图由负转正 | 多头动能开始占优 |
| 柱状图由正转负 | 空头动能开始占优 |
**形象比喻**MACD 像赛跑中两个运动员的"领先差距"。快选手(短期均线)超过慢选手(长期均线)→ 金叉;被超过 → 死叉。
### 3.5 布林带 (Bollinger Bands)
**布林带解决的问题**:建立价格的"正常波动区间",判断当前价格是"正常"还是"过度偏离"。
**三条线**
$$\text{中轨 (Middle Band)} = \text{SMA}(20)$$
$$\text{上轨 (Upper Band)} = \text{SMA}(20) + 2\sigma_{20}$$
$$\text{下轨 (Lower Band)} = \text{SMA}(20) - 2\sigma_{20}$$
其中 $\sigma_{20}$ 是 20 日滚动标准差 (Rolling Standard Deviation)。
**含义**:在正态分布假设下,价格有 95% 的概率在上下轨之间运动。
| 价格位置 | 信号 |
|----------|------|
| 触碰下轨 | 超卖区域,考虑买入 |
| 触碰上轨 | 超买区域,考虑卖出 |
| **带宽收窄 (Band Squeeze)** | 波动率降低,预示即将有大行情 |
| 带宽突然扩大 | 大行情开始,追方向 |
---
## 4. 策略 A双均线金叉/死叉 (MA Crossover)
### 4.1 策略逻辑
这是最经典的趋势跟随策略,逻辑非常直观:
```
短期均线20日代表"近期走势"
长期均线60日代表"长期趋势基准"
金叉Golden Cross:
短期均线 从下方穿过 长期均线 → 近期走势开始强于长期 → 买入信号 🟢
死叉Death Cross:
短期均线 从上方穿过 长期均线 → 近期走势开始弱于长期 → 卖出平仓信号 🔴
```
### 4.2 为什么用"均线上穿"而不是"价格本身"
直接看价格太噪声(每天都有小涨小跌),用均线平滑后只关注真正的趋势转折。这是"**降噪 (Noise Filtering)**"的思想。
### 4.3 信号生成代码详解
```python
# 计算两条均线
ma_short = sma(price, 20) # 短期均线(快线)
ma_long = sma(price, 60) # 长期均线(慢线)
# 原始信号:短线在长线上方为 1看多否则为 0
raw_signal = (ma_short > ma_long).astype(int)
# ⭐ 关键步骤:信号向后移动一天,避免前视偏差
# 今天收盘后判断方向,明天开盘才能交易
# 如果不 shift(1),等于"今天收盘价知道后立刻成交"——这在现实中做不到
ma_signal = raw_signal.shift(1).fillna(0)
```
**为什么一定要 `shift(1)`**
假设均线在今天收盘后16:00产生了金叉信号。现实中你看到这个信号后最早也只能在**明天的开盘**9:30才能下单。如果不 shift相当于"今天收盘价一出来就瞬间买入",这是不可能做到的,这叫**未来函数 (Future Function)** 或**前视偏差 (Lookahead Bias)**。
### 4.4 策略的适用条件与局限
**适合**
- 有明显趋势的市场(牛市单边上涨,熊市单边下跌)
- 日线、周线级别的中长线交易
**不适合**
- 震荡市(横盘整理):频繁金叉死叉,反复"买高卖低",交易成本侵蚀利润
- 高频交易:滞后性太大
---
## 5. 策略 BRSI 均值回归 (RSI Mean Reversion)
### 5.1 策略逻辑
这是**逆势策略 (Contrarian Strategy)**,核心思想:
> "市场短期会过度反应,极端走势终将回归正常。"
> "Markets overreact in the short term, and extreme moves will eventually revert."
```
RSI < 30超卖市场跌过头了 做多买入等反弹 🟢
RSI > 70超买市场涨过头了 → 做空(卖出),等回落 🔴
出场规则Exit Rules
多仓持有中RSI 回升到 50 以上 → 平仓多头(涨回去了,获利了结)
空仓持有中RSI 回落到 50 以下 → 平仓空头(跌回去了,获利了结)
```
### 5.2 均值回归的理论基础
**均值回归 (Mean Reversion)** 是统计学中的一个概念:当一个量偏离其长期均值过远时,有向均值靠拢的倾向。
在金融市场中表现为:
- 市场恐慌性下跌后,往往会有技术性反弹
- 过度追涨后,往往会有获利回吐
- 短期波动会均值回归,但长期趋势可能持续(这就是趋势策略存在的理由)
### 5.3 为什么 RSI 策略在本 Demo 中表现差?
看本 Demo 的回测结果RSI 策略的夏普比率为负数,而 Buy & Hold 为正。
原因很简单:我们的模拟数据用的是 GBM设定了 `mu = 0.10`(年化 10% 的正漂移)。这意味着数据有**明显的上涨趋势**。均值回归策略在趋势市中会频繁做空RSI 超买),而市场偏偏持续上涨,造成亏损。
**结论**:没有"万能"策略。策略的有效性依赖市场环境:
- 趋势市 → 趋势策略赢
- 震荡市 → 均值回归策略赢
- 如何判断当前市场状态?这正是更高级的量化研究课题(策略择时、机制识别)
---
## 6. 向量化回测引擎 (Vectorized Backtest Engine)
### 6.1 什么是向量化?
**向量化 (Vectorization)** 指用 numpy/pandas 的数组运算一次性计算所有日期的结果,而不是用 Python `for` 循环逐日计算。
```python
# 非向量化(慢)
results = []
for i in range(len(prices)):
r = signal[i] * daily_return[i] # 逐日计算
results.append(r)
# 向量化pandas 内部是 C 语言实现)
strategy_returns = signal * daily_returns # 整个序列一次运算
```
**速度差异**:对于 1000 天的数据,向量化通常比 for 循环快 100-1000 倍。
### 6.2 回测核心逻辑
```python
# 1. 每日收益率
daily_ret = prices.pct_change().fillna(0)
# 2. 策略每日收益率(持仓方向 × 市场收益率)
# signal = 1 时,策略收益 = 市场收益(同向)
# signal = 0 时,策略收益 = 0空仓不参与
# signal = -1 时,策略收益 = -市场收益(反向做空)
strat_ret_gross = signal * daily_ret
# 3. 扣除交易成本
position_change = signal.diff().abs() # 仓位变化量(换手)
cost = position_change * cost_per_trade # 每次换手扣除成本
# 4. 净收益率
strat_ret_net = strat_ret_gross - cost
# 5. 净值曲线(每日净收益率连乘)
equity = initial_capital * (1 + strat_ret_net).cumprod()
```
### 6.3 交易成本的重要性
**成本模型 (Cost Model)**
| 成本项 | 说明 | 典型值A 股)|
|--------|------|--------------|
| **佣金 (Commission)** | 券商按交易金额收取 | 万分之三0.03%)单边 |
| **印花税 (Stamp Duty)** | A 股卖出时收取 | 万分之五0.05%),仅卖出 |
| **滑点 (Slippage)** | 信号价格与实际成交价的差 | 视流动性而定,通常 1-5 bp |
**一个典型的坑**:回测不计成本,结果漂亮,一上实盘就亏钱。
高频交易尤其明显:哪怕每次 0.1% 的成本,一天交易 10 次就是 1% 的消耗。
### 6.4 净值曲线 (Equity Curve) 的含义
```
初始资金 100万元
│ 每天乘以 (1 + 当天净收益率)
...第 n 天的净值 = 100万 × (1 + r_1) × (1 + r_2) ×× (1 + r_n)
```
净值曲线是策略"假设从第一天以 100 万元开始运行"的**账户价值随时间变化曲线**。
---
## 7. 绩效指标详解 (Performance Metrics)
不能只看"总收益率"——一个策略可能靠运气在某段时间暴赚,但实际上风险极高。需要多维度评估。
### 7.1 总收益率 & 年化收益率 (CAGR)
**总收益率 (Total Return)**
$$\text{Total Return} = \frac{V_{end} - V_{start}}{V_{start}}$$
**年化复合增长率 (CAGR, Compound Annual Growth Rate)**
$$\text{CAGR} = \left(\frac{V_{end}}{V_{start}}\right)^{1/\text{years}} - 1$$
将总收益率折算成"每年平均增长多少",消除回测时间长短的影响,便于不同策略横向比较。
**例子**5 年总收益 100%CAGR = $(2)^{1/5} - 1 \approx 14.9\%$(不是 100%/5 = 20%,因为有复利效应)
### 7.2 夏普比率 (Sharpe Ratio)
$$\text{Sharpe} = \frac{\text{年化收益率} - r_f}{\text{年化波动率}} = \frac{\bar{r} - r_f}{\sigma} \times \sqrt{252}$$
- $r_f$:无风险利率 (Risk-Free Rate),通常用国债利率,代码中简化为 0
- **含义**:每承担 1 单位波动率,获得多少超额收益
| 夏普比率 | 评级 |
|----------|------|
| < 0 | 不如存钱还有风险|
| 0 ~ 1 | 可接受,但不优秀 |
| 1 ~ 2 | 良好,值得关注 |
| > 2 | 优秀(或者有数据问题,需检查)|
| > 3 | 通常意味着回测存在 bug 或严重过拟合 |
**局限**:夏普比率假设收益率服从正态分布。如果有"小涨长期 + 偶尔大跌"的特征(如卖期权策略),夏普比率会虚高。
### 7.3 索提诺比率 (Sortino Ratio)
$$\text{Sortino} = \frac{\text{CAGR} - r_f}{\text{下行波动率 (Downside Std)}}$$
**与夏普的区别**:夏普把所有波动(涨和跌)都视为"风险";索提诺**只把亏损日的波动**视为风险(正向波动是好事,不应被惩罚)。
对于有正偏态(不对称上涨)的策略,索提诺比夏普更公平。
### 7.4 最大回撤 (Maximum Drawdown)
$$\text{Drawdown}(t) = \frac{V_t - \max_{s \leq t} V_s}{\max_{s \leq t} V_s}$$
$$\text{Max Drawdown} = \min_t \text{Drawdown}(t)$$
**含义**:从账户价值的历史最高点,最大跌了多少百分比。
**为什么这是最重要的风险指标之一?**
- 假设你的策略总收益 200%,但中间经历了 -70% 的最大回撤
- 你有没有能力扛过 -70% 而不放弃?(心理和资金层面)
- 如果你在最大回撤开始前一年进场,可能等了很多年才回本
**经验规则**:最大回撤超过 30% 的策略,很少有人能真实执行。
### 7.5 卡玛比率 (Calmar Ratio)
$$\text{Calmar} = \frac{\text{CAGR}}{|\text{Max Drawdown}|}$$
每承担 1 单位的最大回撤,获得多少年化收益。适合用于比较不同策略的"收益/最大损失"性价比。
### 7.6 胜率 & 盈亏比 (Win Rate & Profit Factor)
**日胜率 (Daily Win Rate)**:有收益的交易日 / 总交易日
**盈亏比 (Profit Factor)**
$$\text{Profit Factor} = \frac{\text{总盈利金额}}{\text{总亏损金额(绝对值)}}$$
> Profit Factor > 1 意味着策略是盈利的(赚的比亏的多)
**一个重要的直觉**:胜率和盈亏比是跷跷板关系。
| 策略类型 | 典型胜率 | 典型盈亏比 |
|----------|----------|------------|
| 趋势跟随(如本 Demo 的 MA 策略)| 30%-40%(多次小亏)| > 2偶尔大赚|
| 均值回归(高频做市)| 60%-80%(频繁小赚)| < 1.5偶尔大亏|
---
## 8. 滚动前向验证与参数优化 (Walk-Forward Validation)
### 8.1 过拟合的危害
**过拟合 (Overfitting)** 是量化回测最大的陷阱:
> 在相同的历史数据上测试足够多的参数组合,总能找到一组让历史表现完美的参数。
> 但这些参数可能只是"碰巧"匹配了历史噪声,在未来数据上会失效。
**例子**:如果你测试了 1000 种均线参数组合,在同一段历史数据上,必然有几组看起来"完美",纯属随机巧合。这叫**数据窥探偏差 (Data Snooping Bias)** 或 **p-hacking**
### 8.2 前向验证的正确做法
```
完整历史数据1500天
├─ 训练集 / In-Sample (80%1200天)
│ 用于:网格搜索最优参数
│ 报告:训练集夏普(仅供参考)
└─ 测试集 / Out-of-Sample (20%300天)
用于:用找到的"最优参数"在这段数据上跑一遍
报告:测试集夏普(这才是真正的绩效评估)
原则:这 300 天的数据在优化参数时绝对不能碰过!
```
**夏普衰减 (Sharpe Decay)**
通常,训练集夏普 > 测试集夏普。这是正常的,问题在于衰减多少:
- 训练集 2.0,测试集 1.5 → 轻微过拟合,可以接受
- 训练集 3.0,测试集 -0.5 → 严重过拟合,该策略不可用
### 8.3 网格搜索 (Grid Search)
```python
for sw in [5, 10, 15, 20, 30]: # 短窗口候选值
for lw in [30, 40, 50, 60, 80, 100]: # 长窗口候选值
if sw >= lw:
continue # 逻辑约束:短窗口必须 < 长窗口
# 在训练集上运行回测,记录夏普
...
# 选出夏普最高的参数组合
# 再在测试集上验证
```
**更严格的验证方法**(代码未实现,但应了解):
**滚动前向验证 (Rolling Walk-Forward)**
```
[Train1] → [Test1]
[Train2] → [Test2]
[Train3] → [Test3]
...
汇报所有 Test 期的拼接结果
```
---
## 9. 收益率分布分析 (Return Distribution Analysis)
### 9.1 为什么要分析收益率分布?
夏普比率的计算假设收益率服从正态分布。如果分布严重偏离正态,夏普比率就不可靠。
### 9.2 偏度 (Skewness)
$$\text{Skewness} = \frac{\frac{1}{n}\sum(r_i - \bar{r})^3}{\sigma^3}$$
| 偏度值 | 含义 |
|--------|------|
| 接近 0 | 左右对称(正态分布)|
| > 0 | 右偏(右尾长),有"意外大赚"的可能性 |
| < 0 | 左偏左尾长"意外大亏"的可能性 |
**金融数据的现实**:大多数股票收益率分布是**负偏态**(崩盘比暴涨更极端)。
### 9.3 峰度 (Kurtosis / Excess Kurtosis)
- 正态分布的峰度 = 0代码中 pandas 计算的是超额峰度)
- 金融收益率通常峰度 > 0**厚尾Fat Tails**
**含义**极端事件±3σ 以外)发生的频率比正态分布预测的更高。这意味着基于正态分布计算的风险模型(如 VaR会低估实际风险。
### 9.4 月度收益热力图 (Monthly Return Heatmap)
热力图直观展示每个月的盈亏情况:
- 🟢 绿色 → 盈利月份
- 🔴 红色 → 亏损月份
- 颜色越深 → 盈亏越大
**用途**:快速识别策略的"季节性"——某些策略在特定月份/季节表现特别好或特别差,这可能是利用了真实的市场规律,也可能是偶然的样本特征。
---
## 10. 术语速查表
| 中文 | English | 简要说明 |
|------|---------|----------|
| 技术分析 | Technical Analysis | 用历史价格/成交量预测未来走势 |
| 趋势跟随 | Trend Following | 顺势而为,买入上涨资产 |
| 均值回归 | Mean Reversion | 逆势操作,买入超跌资产 |
| 技术指标 | Technical Indicator | 从原始数据提炼交易信号的数学公式 |
| 简单移动平均线 | SMA (Simple Moving Average) | n 日收盘价的算术平均 |
| 指数移动平均线 | EMA (Exponential Moving Average) | 近期数据权重更高的加权平均 |
| 相对强弱指数 | RSI (Relative Strength Index) | 衡量涨跌动能的 0-100 震荡指标 |
| 超买 | Overbought | RSI > 70涨势过强 |
| 超卖 | Oversold | RSI < 30跌势过强 |
| 指数平滑异同移动平均 | MACD | 两条 EMA 之差,衡量趋势动能 |
| 布林带 | Bollinger Bands | 均线 ± 2 倍标准差的价格通道 |
| 带宽 | Band Width | 上轨与下轨之差,衡量波动率 |
| 金叉 | Golden Cross | 短线上穿长线,看涨信号 |
| 死叉 | Death Cross | 短线下穿长线,看跌信号 |
| 信号生成 | Signal Generation | 根据指标产生买卖指令的逻辑 |
| 前视偏差 | Lookahead Bias | 回测中使用了"当时无法知道"的未来数据 |
| 向量化回测 | Vectorized Backtest | 用数组运算一次性计算所有时间步 |
| 净值曲线 | Equity Curve | 账户价值随时间变化的曲线 |
| 无风险利率 | Risk-Free Rate | 国债等无风险资产的收益率 |
| 夏普比率 | Sharpe Ratio | 每单位总风险对应的超额收益 |
| 索提诺比率 | Sortino Ratio | 每单位下行风险对应的超额收益 |
| 最大回撤 | Maximum Drawdown (MDD) | 净值从峰值跌落的最大幅度 |
| 卡玛比率 | Calmar Ratio | CAGR 除以最大回撤 |
| 年化复合增长率 | CAGR | 考虑复利后的年均增长率 |
| 胜率 | Win Rate | 盈利次数 / 总次数 |
| 盈亏比 | Profit Factor | 总盈利 / 总亏损 |
| 过拟合 | Overfitting | 策略过度适配历史数据,泛化能力差 |
| 数据窥探偏差 | Data Snooping Bias | 在同一数据上测试太多参数导致的虚假优化 |
| 训练集 | In-Sample (IS) | 用于优化参数的历史数据段 |
| 测试集 | Out-of-Sample (OOS) | 用于验证的未见过的历史数据段 |
| 滚动前向验证 | Walk-Forward Validation | 用过去优化参数,在紧接着的未来上验证 |
| 网格搜索 | Grid Search | 枚举所有参数组合的暴力搜索法 |
| 正态分布 | Normal Distribution | 均值两侧对称的钟形分布 |
| 厚尾 | Fat Tails (Leptokurtosis) | 极端事件频率高于正态分布预测 |
| 偏度 | Skewness | 分布的左右不对称程度 |
| 峰度 | Kurtosis | 分布尾部的厚度(与正态分布对比)|
| 几何布朗运动 | GBM (Geometric Brownian Motion) | 描述股价随机游走的经典数学模型 |
| 年化波动率 | Annualized Volatility | 日波动率 × √252 |
| 滑点 | Slippage | 信号价格与实际成交价的差 |
| 佣金 | Commission | 券商收取的交易手续费 |
---
*上一篇:[数据管道](doc_01_data_pipeline.md)*
*下一篇:[事件驱动回测引擎](doc_03_event_driven_backtest.md)*

View File

@ -0,0 +1,688 @@
# 事件驱动回测引擎详解
## `quant_event_driven_backtest_demo.py` 学习文档
> **目标读者**:零基础量化入门者
> **配套文件**`quant_event_driven_backtest_demo.py`
> **前置知识**:建议先阅读前两篇文档
> **系列位置**:第 3 篇 — 事件驱动回测篇
---
## 目录
1. [为什么需要事件驱动回测?](#1-为什么需要事件驱动回测)
2. [整体架构总览](#2-整体架构总览)
3. [事件类详解 (Event Classes)](#3-事件类详解-event-classes)
4. [数据处理器 (DataHandler)](#4-数据处理器-datahandler)
5. [策略引擎 (Strategy)](#5-策略引擎-strategy)
6. [组合管理器 (Portfolio)](#6-组合管理器-portfolio)
7. [模拟券商 (SimulatedBroker)](#7-模拟券商-simulatedbroker)
8. [回测引擎主循环 (BacktestEngine)](#8-回测引擎主循环-backtest-engine)
9. [绩效分析](#9-绩效分析-performance-analytics)
10. [事件驱动 vs 向量化:结果差异解析](#10-事件驱动-vs-向量化结果差异解析)
11. [术语速查表](#11-术语速查表)
---
## 1. 为什么需要事件驱动回测?
在上一篇文档中,我们用**向量化回测**验证了策略。向量化回测速度极快,适合快速探索,但它有一个根本局限:
> **向量化回测是"上帝视角"** —— 所有数据同时可见,一次性计算完毕。
> **真实交易是"时间顺序"** —— 只有当前这根 K 线,下一根是未知的。
### 1.1 向量化回测无法模拟的真实问题
| 现实场景 | 向量化的处理 | 事件驱动的处理 |
|----------|-------------|---------------|
| 订单类型 (Order Types) | 只能假设"立即以当日收盘价成交" | 可以模拟市价单/限价单/止损单 |
| 成交延迟 (Fill Delay) | 信号日 = 成交日(不现实)| 信号日 T下一日 T+1 开盘才成交 |
| 资金限制 (Capital Constraints) | 隐性假设可以买任意多 | 按实际现金余额决定买多少股 |
| 整数股 (Integer Shares) | 往往假设可以买分数股 | 强制取整(现实中必须整数)|
| 实时风控 (Real-time Risk Check) | 无法在"每笔交易"时检查 | 每个 OrderEvent 前都可以检查 |
| 多策略组合 (Multi-Strategy) | 难以优雅处理 | 每个策略独立处理 MarketEvent |
| 实盘无缝切换 | 无法复用代码 | 策略代码在回测和实盘中完全相同 |
### 1.2 核心优势:回测代码 = 实盘代码
事件驱动的最大价值是:你的策略类 (Strategy)、组合管理器 (Portfolio) 在回测中和实盘中运行的是**完全相同的代码**。
切换到实盘只需要将 `DataHandler`(历史数据)换成实时行情 API`SimulatedBroker`(模拟券商)换成真实券商 API。
```
回测模式: DataHandler(历史CSV) → 策略 → 组合 → SimulatedBroker(模拟)
实盘模式: MarketDataFeed(API) → 策略 → 组合 → RealBroker(真实券商API)
↑ 这两行代码完全不变 ↑
```
---
## 2. 整体架构总览
### 2.1 系统的核心:事件队列
整个系统围绕一个**事件队列 (Event Queue)** 运转。所有组件都通过这个队列通信,彼此之间**完全解耦 (Decoupled)**——没有任何组件直接调用另一个组件,它们只是往队列里放事件,或者从队列里取事件。
```
┌─────────────────────────────────┐
│ 事件队列 (Event Queue) │
│ 先进先出 (FIFO deque) │
│ ← 放入事件 取出事件 → │
└─────────────────────────────────┘
↑↑↑↑↑↑↑
┌──────────┬─────────┴──────────┬──────────┐
│ │ │ │
DataHandler Strategy Portfolio SimulatedBroker
数据处理器 策略引擎 组合管理器 模拟券商
│ │ │ │
放入 Market 放入 Signal 放入 Order 放入 Fill
Event Event Event Event
```
### 2.2 单个交易日的事件流
每天,系统会经历以下事件序列:
```
⏰ 时钟滴答:新的一个交易日开始
[1] DataHandler.stream_next()
→ 将今天的 OHLCV 数据封装成 MarketEvent 放入队列
[2] SimulatedBroker.fill_pending()
→ 以今天的开盘价成交昨天挂的待处理订单
→ 成交后产生 FillEvent 放入队列
[3] 处理队列中的所有事件:
├─ MarketEvent → Strategy.on_market()
│ 策略看到今天的价格 → 可能产生 SignalEvent
├─ SignalEvent → Portfolio.on_signal()
│ 组合管理器决定买多少 → 产生 OrderEvent
├─ OrderEvent → SimulatedBroker.on_order()
│ 券商接收订单 → 加入待处理队列(明天开盘成交)
└─ FillEvent → Portfolio.on_fill()
更新现金余额和持仓记录
[4] Portfolio.record_equity()
→ 记录今天收盘时的账户净值快照
⏰ 时钟滴答:下一个交易日开始(回到步骤 1
```
**为什么第 2 步(成交待处理订单)在第 3 步(处理队列)之前?**
这是防止前视偏差的关键设计:
- 今天T日策略看到价格后产生信号挂了一个订单
- 这个订单在今天不成交(因为今天的收盘价就是信号触发的那根 K 线)
- 明天T+1 日)开盘时,才以开盘价成交这个订单
- 这正确模拟了现实中"当天看到信号,明天才能买到"的情况
---
## 3. 事件类详解 (Event Classes)
代码定义了 4 种事件类,像快递单据一样,每类事件携带不同的信息。
### 3.1 MarketEvent市场事件
**触发时机**DataHandler 每天推送新的 K 线数据时
**携带信息**OHLCV 数据(开高低收量)
```python
@dataclass
class MarketEvent:
symbol : str # 股票代码 例: "000001.SZ"
date : pd.Timestamp # 日期 例: 2024-01-15
open : float # 开盘价 (Opening Price)
high : float # 最高价 (High Price)
low : float # 最低价 (Low Price)
close : float # 收盘价 (Closing Price)
volume : float # 成交量 (Volume)
```
**OHLCV 是什么?**
一根 K 线Candlestick / Bar记录了某个时间段内的价格运动全貌
- **O (Open 开盘价)**:这个时间段第一笔成交价
- **H (High 最高价)**:这个时间段最高成交价
- **L (Low 最低价)**:这个时间段最低成交价
- **C (Close 收盘价)**:这个时间段最后一笔成交价
- **V (Volume 成交量)**:这个时间段总成交股数
### 3.2 SignalEvent信号事件
**触发时机**:策略判断应该开仓/平仓时
**携带信息**:交易方向、信号强度
```python
@dataclass
class SignalEvent:
symbol : str # 股票代码
date : pd.Timestamp
direction : Direction # LONG做多/ SHORT做空/ EXIT平仓
strength : float # 信号强度 0~1用于仓位管理
# 1.0 = 满仓0.5 = 半仓
strategy : str # 产生信号的策略名称(多策略时有用)
```
**关键区别**SignalEvent 是"建议",不是"命令"。
Portfolio 收到 SignalEvent 后,还会根据风控规则决定是否执行、执行多少。
### 3.3 OrderEvent订单事件
**触发时机**Portfolio 决定下单后
**携带信息**:具体下单量、订单类型
```python
@dataclass
class OrderEvent:
symbol : str # 股票代码
date : pd.Timestamp
order_type : OrderType # MARKET市价单/ LIMIT限价单/ STOP止损单
direction : Direction # LONG / SHORT / EXIT
quantity : int # 下单数量(股数,整数!)
limit_price : float # 限价单价格(仅限价单用)
stop_price : float # 止损价格(仅止损单用)
```
**三种订单类型**
| 类型 | 中文 | 说明 | 优点 | 缺点 |
|------|------|------|------|------|
| **MARKET** | 市价单 | 立即以当前最优价成交 | 100% 成交 | 价格不确定,大单可能有较大滑点 |
| **LIMIT** | 限价单 | 只在指定价格或更好时成交 | 价格确定 | 可能无法成交(市场未到你的价格)|
| **STOP** | 止损单 | 价格到达止损价时转为市价单 | 控制最大亏损 | 跳空时实际成交价可能比止损价更差 |
本 Demo 只实现了市价单(最简单),限价单和止损单留作扩展练习。
### 3.4 FillEvent成交事件
**触发时机**:模拟券商确认订单已成交后
**携带信息**:实际成交价(含滑点)、佣金
```python
@dataclass
class FillEvent:
symbol : str
date : pd.Timestamp
direction : Direction # LONG / SHORT / EXIT
quantity : int # 实际成交股数
fill_price : float # 实际成交价(已含滑点,比理论价差一点)
commission : float # 本次交易的佣金金额(元)
slippage : float # 滑点额(实际成交价 - 开盘价)
@property
def total_cost(self):
# 买入:现金减少(负号)
# 卖出:现金增加(正号)
sign = -1 if direction in (LONG, SHORT) else +1
return sign * (fill_price * quantity + commission)
```
**FillEvent 是最终事实 (Ground Truth)**——一旦产生就意味着交易确实发生了Portfolio 必须据此更新账户状态。
---
## 4. 数据处理器 (DataHandler)
### 4.1 核心职责
DataHandler 负责**逐根 K 线**地向系统"喂"数据,模拟"不知道未来"的真实状态。
```python
class DataHandler:
def stream_next(self) -> bool:
"""
每次调用,只释放"下一根 K 线"的数据。
就像真实交易中,每天只能看到当天的数据,
明天的数据还没发生。
"""
...
self.event_queue.put(bar) # 把新 K 线放入队列
```
### 4.2 历史记录与指标计算
DataHandler 还维护了一个"已经看到的 K 线"的历史记录,供策略查询:
```python
def get_close_series(self) -> pd.Series:
"""
返回到目前为止所有已见过的收盘价序列。
策略可以用这个序列计算均线、RSI 等指标。
关键:这个序列只包含"过去"的数据,不包含任何"未来"数据。
这从结构上保证了不会有前视偏差。
"""
```
**为什么不直接给策略整个数据集?**
这正是事件驱动的防错设计——即使你不小心让策略代码 `data.get_close_series()` 在某天调用,它也只能拿到"截至今天"的数据,不可能拿到明天的收盘价,因为 DataHandler 只把已经流出去的数据存入历史记录。
---
## 5. 策略引擎 (Strategy)
### 5.1 抽象基类设计
代码用 Python 的**抽象基类 (Abstract Base Class, ABC)** 定义了策略的"接口合同"
```python
class Strategy(ABC):
@abstractmethod
def on_market(self, event: MarketEvent) -> None:
"""所有策略都必须实现这个方法"""
...
```
**好处**
- 任何具体策略MA策略、RSI策略、机器学习策略只需继承 `Strategy` 并实现 `on_market`
- 回测引擎不关心策略具体怎么计算,只知道"来了 MarketEvent 就调用 on_market"
- 这叫**开放封闭原则 (Open-Closed Principle)**:对扩展开放,对修改封闭
### 5.2 MA 策略的事件驱动实现
向量化版本(上一篇)一次性计算了所有日期的均线。
事件驱动版本在**每一根新 K 线到来时**,只用截至今天的历史数据重新计算一次:
```python
class MACrossoverStrategy(Strategy):
def on_market(self, event: MarketEvent) -> None:
# 从 DataHandler 获取迄今为止的所有收盘价(不含未来)
closes = self.data.get_close_series()
# 只用"窗口内最近的数据"计算当前均线值
sma_short = closes.iloc[-self.short_window:].mean()
sma_long = closes.iloc[-self.long_window:].mean()
# 检测交叉
sma_short_prev = closes.iloc[-(self.short_window+1):-1].mean()
sma_long_prev = closes.iloc[-(self.long_window+1):-1].mean()
was_above = sma_short_prev > sma_long_prev
is_above = sma_short > sma_long
# 金叉:昨天还在下方,今天穿越到上方
if is_above and not was_above and not self._in_position:
self.send_signal(..., Direction.LONG)
self._in_position = True
# 死叉:昨天还在上方,今天穿越到下方
elif not is_above and was_above and self._in_position:
self.send_signal(..., Direction.EXIT)
self._in_position = False
```
**策略内部状态 `_in_position`**
这个布尔变量追踪"当前是否已经持有多仓",防止在已经持仓时重复买入,或者在没有持仓时发出平仓信号。这在向量化回测中很难优雅处理,在事件驱动中则非常自然。
### 5.3 RSI 策略的完整多空逻辑
```python
class RSIMeanReversionStrategy(Strategy):
def on_market(self, event: MarketEvent) -> None:
rsi = self._compute_rsi(closes) # 只用迄今为止的数据
# 空仓时的入场规则Entry Rules
if rsi < 30 and self._position == 0:
self.send_signal(..., Direction.LONG, strength=0.8) # 超卖 → 做多
self._position = 1
elif rsi > 70 and self._position == 0:
self.send_signal(..., Direction.SHORT, strength=0.8) # 超买 → 做空
self._position = -1
# 持仓时的出场规则Exit Rules
elif self._position == 1 and rsi > 50: # 多仓RSI 回到中性 → 平多
self.send_signal(..., Direction.EXIT)
self._position = 0
elif self._position == -1 and rsi < 50: # 空仓RSI 回到中性 平空
self.send_signal(..., Direction.EXIT)
self._position = 0
```
**`strength=0.8` 的含义**
信号强度 0.8 表示"我不是百分之百确定,只动用 80% 的可用资金"。Portfolio 收到这个信号后,会用 `capital × position_pct × strength` 来计算下单金额。
---
## 6. 组合管理器 (Portfolio)
Portfolio 是管理"钱"的大脑,负责:
1. 决定买多少(仓位管理)
2. 更新现金余额和持仓记录
3. 计算盈亏
4. 记录净值曲线
### 6.1 仓位管理:固定比例法 (Fixed Fraction)
```python
def on_signal(self, event: SignalEvent):
current_price = self.data.get_close_series().iloc[-1]
# 计算下单资金 = 当前总资产 × 仓位比例 × 信号强度
capital_to_deploy = self.cash * self.position_pct * event.strength
# 下单股数 = 下单资金 / 当前价格(向下取整,必须整数股)
qty = int(capital_to_deploy / current_price)
```
**仓位管理 (Position Sizing)** 是风险管理的核心。代码使用了最简单的**固定比例法 (Fixed Fraction)**:每次动用总资产的固定比例(如 95%)。
更高级的仓位管理方法:
| 方法 | 中文 | 逻辑 |
|------|------|------|
| Fixed Dollar | 固定金额 | 每次固定买 X 元 |
| Fixed Fraction | 固定比例 | 每次买总资产的 X% |
| **Kelly Criterion** | 凯利公式 | 根据"胜率和盈亏比"计算最优比例,理论上最大化长期增长率 |
| **Volatility Scaling** | 波动率缩放 | 仓位与波动率反比,高波动时减仓,低波动时加仓 |
### 6.2 持仓均价与盈亏计算
```python
def on_fill(self, event: FillEvent):
if direction == LONG:
# 更新持仓均价(加权平均成本)
# 例:已有 1000 股均价 10 元,再买 500 股 @ 12 元
# 新均价 = (1000×10 + 500×12) / 1500 = 10.67 元
new_qty = old_qty + qty
self.avg_cost[symbol] = (old_cost * old_qty + price * qty) / new_qty
# 现金减少(买入)
self.cash -= price * qty + commission
elif direction == EXIT:
# 计算已实现盈亏Realized P&L
# 盈亏 = (出场价 - 入场均价) × 数量 - 佣金
pnl = (exit_price - entry_price) * qty - commission
# 现金增加(卖出回款)
self.cash += exit_price * qty - commission
```
**两种盈亏概念**
| 概念 | 中文 | 含义 |
|------|------|------|
| **Unrealized P&L** | 浮动盈亏 / 未实现盈亏 | 持仓中但尚未卖出的账面盈亏 |
| **Realized P&L** | 已实现盈亏 | 已经卖出、真正落袋的盈亏 |
"浮盈不是赚,浮亏不是亏"——只有平仓后才算真正的盈亏。
---
## 7. 模拟券商 (SimulatedBroker)
### 7.1 两大成本来源
**① 佣金 (Commission)**
$$\text{佣金} = \max(\text{交易金额} \times \text{佣金率},\ \text{最低佣金})$$
代码中:`commission_rate = 0.0003`(万分之三),`min_commission = 5.0`(最低 5 元)
最低佣金很重要:小额交易(如 500 元的单子)理论佣金 = 500 × 0.03% = 0.15 元,但实际按 5 元收取,成本率高达 1%
**② 滑点 (Slippage)**
```python
def _compute_fill_price(self, order, bar):
if direction == LONG:
# 买入时,实际成交价比开盘价"稍贵"(滑点向上)
return bar.open * (1 + self.slippage_rate)
else:
# 卖出时,实际成交价比开盘价"稍便宜"(滑点向下)
return bar.open * (1 - self.slippage_rate)
```
**滑点向不利方向运动的原因**
- 你想买的时候,有人知道你要买,会把价格稍稍抬高(市场冲击)
- 你想卖的时候,买家会压低报价
### 7.2 订单的执行时机
```
今天T 日)收盘后:策略产生信号 → Portfolio 挂出 OrderEvent
今天T 日收盘时Broker 收到订单,加入 pending_orders 列表
⚠️ 注意:今天不成交!
明天T+1 日开盘时Broker 以今天的开盘价成交昨天挂的所有 pending 订单
→ 产生 FillEvent更新 Portfolio
```
**代码逻辑**
```python
# 每天开始时,先把昨天的待处理订单用今天的开盘价成交
def fill_pending(self, bar: MarketEvent):
for order in self._pending_orders:
fill_price = bar.open * (1 ± slippage) # 今天的开盘价 ± 滑点
commission = max(fill_price * qty * rate, min_comm)
self.queue.put(FillEvent(...))
self._pending_orders.clear()
```
---
## 8. 回测引擎主循环 (Backtest Engine)
### 8.1 主循环算法全貌
```python
def run(self):
# 外层循环:每天执行一次
while self.data.has_more_data():
# ① 推进一天DataHandler 推送今天的 MarketEvent
self.data.stream_next()
current_bar = self.data._history[-1] # 今天的 K 线
# ② 成交待处理订单(以今天开盘价)
# 昨天的信号在今天才能执行,这正确模拟了成交延迟
self.broker.fill_pending(current_bar)
# ③ 处理队列中所有事件(内层循环)
while not self._queue.empty():
event = self._queue.get()
if event.type == MARKET:
self.strategy.on_market(event) # 策略观察市场
elif event.type == SIGNAL:
self.portfolio.on_signal(event) # 组合决定下单量
elif event.type == ORDER:
self.broker.on_order(event) # 券商接收订单
elif event.type == FILL:
self.portfolio.on_fill(event) # 组合更新持仓现金
# ④ 记录今天收盘时的净值快照
self.portfolio.record_equity(current_bar.date, current_bar.close)
```
### 8.2 为什么内层循环很重要?
注意步骤 ③ 是一个内层 `while` 循环,处理**所有**队列中的事件。
这是因为事件会产生新的事件:
- MarketEvent → 策略处理后产生 SignalEvent放入队列
- SignalEvent → Portfolio 处理后产生 OrderEvent放入队列
- 下一次循环开始才处理 OrderEvent
但如果有 FillEvent今天的成交回报它也需要在今天内处理完更新好 Portfolio 状态,才能保证 `record_equity` 记录的是更新后的净值。
### 8.3 工厂函数 (Factory Function)
代码提供了一个 `build_engine` 函数,每次调用都创建**全新、独立的**组件实例:
```python
def build_engine(strategy_class, strategy_kwargs, name, ...):
q = queue.Queue() # ← 全新事件队列,与其他回测完全隔离
data = DataHandler(ohlcv, SYMBOL, q)
strategy = strategy_class(data, q, **strategy_kwargs)
portfolio = Portfolio(data, q, initial_capital=1_000_000.0)
broker = SimulatedBroker(q, ...)
return BacktestEngine(data, strategy, portfolio, broker, name=name)
```
**为什么需要全新实例?**
如果两次回测共享同一个 Portfolio第一次回测的持仓和现金状态会污染第二次回测。每次回测都必须从"干净的初始状态"开始。
---
## 9. 绩效分析 (Performance Analytics)
### 9.1 两个维度的绩效
事件驱动回测比向量化回测多了一个维度:**交易级别 (Trade-Level)** 分析。
| 分析维度 | 数据来源 | 代表指标 |
|----------|----------|----------|
| **日度水平 (Daily Level)** | `equity_curve` 每日净值 | 夏普、最大回撤、年化收益 |
| **交易水平 (Trade Level)** | `trade_log` 每笔交易 | 交易胜率、单笔平均盈亏、盈亏比 |
### 9.2 交易日志 (Trade Log)
每笔完成的"买-卖"一个完整轮次叫**一笔交易 (Round-Trip Trade)**。代码记录了每笔交易:
```python
self.trade_log.append({
"date" : 出场日期,
"symbol" : 股票代码,
"direction" : "LONG→EXIT" 或 "SHORT→EXIT",
"entry_price" : 入场均价(最初买入的均价),
"exit_price" : 出场价格(本次卖出价),
"quantity" : 成交股数,
"pnl" : 盈亏金额 = (出场价 - 入场价) × 股数 - 佣金,
"commission" : 本笔交易总佣金,
})
```
### 9.3 逐笔绩效的价值
通过 `trade_log` 可以深挖很多问题:
- **盈利的交易有多少笔?亏损的有多少笔?**(交易胜率)
- **平均每笔赚多少?每笔亏多少?**(期望值分析)
- **最大单笔亏损是多少?**(单笔风险控制)
- **是否有明显的持仓时长规律?**(持有时间分析,虽然本 Demo 未直接展示)
- **特定月份/季节表现如何?**(季节性)
---
## 10. 事件驱动 vs 向量化:结果差异解析
本 Demo 的最后部分对比了两种方法对**完全相同策略、完全相同数据**的回测结果差异:
```
事件驱动 向量化
总收益率 Total Return 165.00% 173.92%
夏普比率 Sharpe Ratio 0.958 0.950
最大回撤 Max Drawdown -18.95% -20.34%
```
数字有差距,原因如下:
### 差异 1成交时机不同
| | 向量化 | 事件驱动 |
|---|--------|---------|
| 成交价格 | 当天收盘价(信号产生的同一根 K 线)| 下一天开盘价(有 1 天延迟)|
| 现实性 | ❌ 不现实(无法做到)| ✅ 保守但现实 |
在上涨趋势中明天开盘价通常比今天收盘价稍高Gap Up所以事件驱动的买入成本更高收益率更低。
### 差异 2整数股 vs 分数股
| | 向量化 | 事件驱动 |
|---|--------|---------|
| 交易单位 | 分数股(如 123.456 股)| 整数股(如 123 股,向下取整)|
| 现实性 | ❌ 不现实 | ✅ 现实 |
每次取整都会有少量资金无法部署(零头现金),随着股价升高,这个影响会越来越明显。
### 差异 3动态资金 vs 静态假设
| | 向量化 | 事件驱动 |
|---|--------|---------|
| 仓位计算 | 隐性假设固定一个单位 | 每次用当前实际现金余额计算 |
| 效果 | 每笔交易资金相同 | 盈利后仓位增大,亏损后仓位缩小(自动复利)|
事件驱动更真实地模拟了**复利效应 (Compounding Effect)**。
### 差异 4最低佣金
事件驱动设置了 `min_commission = 5 元`。当单笔交易金额较小时,按比例算出的佣金不足 5 元,实际收 5 元,成本率会高于向量化的固定比例假设。
---
## 11. 术语速查表
| 中文 | English | 简要说明 |
|------|---------|----------|
| 事件驱动 | Event-Driven | 通过事件队列驱动组件间通信的软件架构 |
| 事件队列 | Event Queue | 存放待处理事件的先进先出数据结构 |
| 解耦 | Decoupling | 各组件通过事件通信,相互不直接依赖 |
| 市场事件 | MarketEvent | 新 K 线数据到达的事件 |
| 信号事件 | SignalEvent | 策略产生买卖建议的事件 |
| 订单事件 | OrderEvent | 组合管理器向券商下单的事件 |
| 成交事件 | FillEvent | 券商确认订单已成交的事件 |
| 数据处理器 | DataHandler | 逐根 K 线流出历史数据的组件 |
| 策略 | Strategy | 观察市场并产生信号的逻辑层 |
| 抽象基类 | ABC (Abstract Base Class) | Python 强制要求子类实现特定方法的基类 |
| 组合管理器 | Portfolio | 管理现金、持仓、盈亏的组件 |
| 仓位管理 | Position Sizing | 决定每次交易动用多少资金的方法 |
| 固定比例法 | Fixed Fraction | 每次用总资产固定比例的仓位管理方法 |
| 凯利公式 | Kelly Criterion | 理论最优仓位比例的数学公式 |
| 持仓均价 | Average Cost (Entry Price) | 所有买入成本的加权平均价 |
| 已实现盈亏 | Realized P&L | 已平仓确认的盈亏金额 |
| 浮动盈亏 | Unrealized P&L | 持仓中尚未平仓的账面盈亏 |
| 模拟券商 | Simulated Broker | 回测中模拟真实券商行为的组件 |
| 市价单 | Market Order | 立即以最优价成交的订单类型 |
| 限价单 | Limit Order | 只在指定价格或更优时成交的订单 |
| 止损单 | Stop Order | 价格触发后转为市价单的保护性订单 |
| 滑点 | Slippage | 理论价格与实际成交价的差值 |
| 佣金 | Commission | 券商收取的交易手续费 |
| 成交延迟 | Fill Delay | 信号产生到实际成交的时间间隔 |
| 回测引擎 | Backtest Engine | 协调所有组件运行的主循环 |
| 主循环 | Main Event Loop | 驱动整个回测顺序推进的核心循环 |
| K 线 | Candlestick / Bar | 一段时间内的开高低收量数据 |
| OHLCV | OHLCV | Open / High / Low / Close / Volume |
| 开盘价 | Opening Price | 当日第一笔成交价 |
| 最高价 | High Price | 当日最高成交价 |
| 最低价 | Low Price | 当日最低成交价 |
| 收盘价 | Closing Price | 当日最后一笔成交价 |
| 成交量 | Volume | 当日总成交股数 |
| 向量化 | Vectorized | 用数组运算一次性处理所有时间步 |
| 前视偏差 | Lookahead Bias | 回测中使用了"当时未知"的未来数据 |
| 整数股 | Integer Shares | 现实中股票必须以整数股交易 |
| 复利效应 | Compounding Effect | 每期收益都在扩大的本金上继续赚取收益 |
| 成交时机 | Fill Timing | 订单被实际成交的时间点 |
| 信号强度 | Signal Strength | 策略对本次信号的置信度(用于仓位缩放)|
| 圆跳交易 | Round-Trip Trade | 从开仓到平仓的一个完整交易周期 |
| 交易胜率 | Trade Win Rate | 盈利交易次数 / 总交易次数 |
| 净值曲线 | Equity Curve | 账户总价值随时间变化的曲线 |
| 工厂函数 | Factory Function | 用于创建初始化好的完整对象的辅助函数 |
---
*上一篇:[策略开发与向量化回测](doc_02_strategy_backtest.md)*
*系列完结:恭喜你完成了量化交易基础三部曲!*
---
## 附录:系列文档导航
| 篇 | 文件 | 文档 | 核心内容 |
|----|------|------|----------|
| 第 1 篇 | `quant_data_pipeline_demo.py` | `doc_01_data_pipeline.md` | 复权、收益率、缺失值、异常值、涨跌停 |
| 第 2 篇 | `quant_strategy_backtest_demo.py` | `doc_02_strategy_backtest.md` | 技术指标、策略逻辑、向量化回测、绩效指标 |
| 第 3 篇 | `quant_event_driven_backtest_demo.py` | `doc_03_event_driven_backtest.md` | 事件驱动架构、6大组件、成本模型、与向量化对比 |

BIN
event_driven_backtest.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,911 @@
# =============================================================================
# Quantitative Trading — Strategy Development & Backtesting Demo
# 量化交易 — 策略开发与回测演示
# =============================================================================
#
# 本文件是数据管道 (quant_data_pipeline_demo.py) 的续集。
# This file is the sequel to the data pipeline demo.
#
# Topics covered / 涵盖主题:
# 1. Technical Indicators 技术指标 (MA, RSI, MACD, Bollinger Bands)
# 2. Signal Generation 信号生成 (entry & exit rules)
# 3. Two Demo Strategies 两个示范策略:
# A. Dual Moving Average Crossover 双均线金叉死叉策略
# B. RSI Mean Reversion RSI 均值回归策略
# 4. Vectorized Backtest Engine 向量化回测引擎
# 5. Performance Metrics 绩效指标
# (Sharpe, Sortino, Max Drawdown, Win Rate …)
# 6. Visualization 可视化
#
# Prerequisites / 前置条件:
# pip install numpy pandas matplotlib scipy
#
# Running / 运行方式:
# python quant_strategy_backtest_demo.py
# =============================================================================
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from scipy import stats
import warnings
warnings.filterwarnings('ignore')
# 中文字体配置 / Chinese font config
plt.rcParams['font.sans-serif'] = ['WenQuanYi Zen Hei', 'Arial Unicode MS', 'SimHei', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
np.random.seed(42)
print("=" * 70)
print(" 量化交易策略开发与回测演示")
print(" Quantitative Trading: Strategy Development & Backtesting Demo")
print("=" * 70)
# =============================================================================
# SECTION 0: Synthetic Price Data 合成价格数据
# -----------------------------------------------------------------------------
# We simulate a single stock using Geometric Brownian Motion (几何布朗运动),
# the classical model that underlies the Black-Scholes formula.
#
# GBM formula:
# dS = μ·S·dt + σ·S·dW
#
# Discrete form (what we actually compute each day):
# S_t = S_{t-1} · exp( (μ - σ²/2)·dt + σ·√dt·ε )
#
# where:
# μ = drift / 年化漂移率 (expected annual return)
# σ = volatility / 年化波动率
# dt = 1/252 (one trading day as a fraction of a year)
# ε ~ N(0,1) (standard normal random shock / 标准正态随机扰动)
# =============================================================================
def generate_price_series(
n_days: int = 1500,
mu: float = 0.10, # 年化预期收益率 / annual expected return
sigma: float = 0.25, # 年化波动率 / annual volatility
s0: float = 100.0, # 初始价格 / initial price
seed: int = 42,
) -> pd.Series:
"""
Generate a synthetic daily price series via GBM.
用几何布朗运动生成合成日线价格序列
"""
np.random.seed(seed)
dt = 1.0 / 252 # 每个交易日占一年的比例
epsilon = np.random.randn(n_days) # 每日随机冲击
log_returns = (mu - 0.5 * sigma ** 2) * dt + sigma * np.sqrt(dt) * epsilon
prices = s0 * np.exp(np.cumsum(log_returns)) # 累积乘积 → 价格路径
# 生成工作日日期序列 / generate business-day date index
dates = pd.bdate_range(start="2019-01-02", periods=n_days)
return pd.Series(prices, index=dates, name="close")
price = generate_price_series()
print(f"\n[数据] 生成模拟股票价格: {len(price)} 个交易日")
print(f" 价格区间: {price.min():.2f} ~ {price.max():.2f}")
# =============================================================================
# SECTION 1: Technical Indicators 技术指标
# -----------------------------------------------------------------------------
# Technical indicators transform raw price/volume data into signals.
# 技术指标将原始价格/成交量数据转化为交易信号。
#
# They are divided into two broad families:
# 主要分为两大类:
#
# ① Trend-following indicators 趋势跟随指标
# → Moving Averages (MA), MACD
# → Work well in trending markets (趋势市中效果好)
#
# ② Oscillators / Mean-reversion indicators 震荡/均值回归指标
# → RSI, Bollinger Bands
# → Work well in range-bound / choppy markets (震荡市中效果好)
# =============================================================================
# ── 1-A Simple Moving Average 简单移动平均线 (SMA) ──────────────────────────
#
# SMA_n(t) = (P_{t} + P_{t-1} + … + P_{t-n+1}) / n
#
# The SMA smooths out daily noise to reveal the underlying trend.
# SMA 平滑日内噪音,揭示潜在趋势。
# A longer window → smoother, but lags more behind recent price action.
# 窗口越长 → 越平滑,但对价格变化的反应越滞后。
def sma(prices: pd.Series, window: int) -> pd.Series:
"""Simple Moving Average / 简单移动平均线"""
return prices.rolling(window=window).mean()
# ── 1-B Exponential Moving Average 指数移动平均线 (EMA) ───────────────────
#
# EMA gives MORE weight to recent prices (recent data matters more).
# EMA 给予近期价格更高权重(近期数据更重要)。
#
# EMA_t = α · P_t + (1 - α) · EMA_{t-1}
# where α = 2 / (n + 1) (smoothing factor / 平滑因子)
#
# EMA reacts faster than SMA to price changes.
# EMA 对价格变动的反应比 SMA 更灵敏。
def ema(prices: pd.Series, span: int) -> pd.Series:
"""Exponential Moving Average / 指数移动平均线"""
return prices.ewm(span=span, adjust=False).mean()
# ── 1-C RSI 相对强弱指数 (Relative Strength Index) ─────────────────────────
#
# RSI measures the speed and magnitude of recent price changes.
# RSI 衡量近期价格变动的速度和幅度。
#
# Formula:
# RS = average_gain / average_loss (over last n days)
# RSI = 100 - 100 / (1 + RS)
#
# Interpretation / 指标解读:
# RSI > 70 → Overbought 超买 (price may be due for a pullback / 价格可能回调)
# RSI < 30 → Oversold 超卖 (price may be due for a bounce / 价格可能反弹)
# RSI = 50 → Neutral 中性
def rsi(prices: pd.Series, window: int = 14) -> pd.Series:
"""
Compute Wilder's RSI.
计算 Wilder 平滑法 RSI
"""
delta = prices.diff() # 每日价格变化 / daily price change
gain = delta.clip(lower=0) # 只保留上涨部分 / keep only up-days
loss = -delta.clip(upper=0) # 只保留下跌部分 / keep only down-days
# Wilder uses EMA with span = 2*n - 1 (equivalent to 1/n smoothing)
avg_gain = gain.ewm(alpha=1.0 / window, adjust=False).mean()
avg_loss = loss.ewm(alpha=1.0 / window, adjust=False).mean()
rs = avg_gain / avg_loss # 相对强弱值 / relative strength
return 100 - (100 / (1 + rs)) # 转换为 0~100 范围
# ── 1-D MACD 指数平滑异同移动平均线 ────────────────────────────────────────
#
# MACD reveals the relationship between two EMAs.
# MACD 揭示两条 EMA 之间的关系。
#
# Components / 构成:
# MACD Line MACD线 = EMA(12) - EMA(26) (fast minus slow / 快线减慢线)
# Signal Line 信号线 = EMA(9) of MACD Line (trigger line / 触发线)
# Histogram 柱状图 = MACD Line - Signal Line
#
# Trading rules / 交易规则:
# MACD crosses above Signal → Bullish (金叉, buy signal / 买入信号)
# MACD crosses below Signal → Bearish (死叉, sell signal / 卖出信号)
def macd(prices: pd.Series,
fast: int = 12, slow: int = 26, signal: int = 9
) -> pd.DataFrame:
"""
Compute MACD, Signal line, and Histogram.
计算 MACD线信号线和柱状图
"""
ema_fast = ema(prices, fast)
ema_slow = ema(prices, slow)
macd_line = ema_fast - ema_slow # MACD 线
signal_line = ema(macd_line, signal) # 信号线 (DIF的EMA)
histogram = macd_line - signal_line # 柱状图 (MACD Bar)
return pd.DataFrame({
"macd": macd_line,
"signal": signal_line,
"histogram": histogram,
})
# ── 1-E Bollinger Bands 布林带 ─────────────────────────────────────────────
#
# Bollinger Bands place upper/lower envelopes around a moving average.
# 布林带在移动平均线上下各画一条"包络线"。
#
# Formula:
# Middle Band 中轨 = SMA(n)
# Upper Band 上轨 = SMA(n) + k·σ_n (k = 2 by default / 默认 k=2)
# Lower Band 下轨 = SMA(n) - k·σ_n
#
# where σ_n is the rolling standard deviation / 滚动标准差
#
# When price touches the lower band → oversold area (超卖区域)
# When price touches the upper band → overbought area (超买区域)
# Band width (带宽) contracts before explosive moves (波动收窄常预示突破)
def bollinger_bands(prices: pd.Series, window: int = 20, k: float = 2.0
) -> pd.DataFrame:
"""
Compute Bollinger Bands.
计算布林带上轨中轨下轨
"""
mid = sma(prices, window) # 中轨 (SMA)
std = prices.rolling(window).std() # 滚动标准差
upper = mid + k * std # 上轨
lower = mid - k * std # 下轨
# %B indicator: where is the current price within the band?
# %B 指标:当前价格在带宽中的位置 (0=下轨, 1=上轨)
pct_b = (prices - lower) / (upper - lower)
return pd.DataFrame({
"upper": upper, "mid": mid, "lower": lower, "pct_b": pct_b
})
# Compute all indicators on our simulated price series
# 对模拟价格序列计算所有指标
sma20 = sma(price, 20) # 20日均线 / 20-day SMA
sma60 = sma(price, 60) # 60日均线 / 60-day SMA (longer trend)
rsi14 = rsi(price, 14) # 14日RSI / 14-day RSI
macd_df = macd(price) # MACD (12/26/9)
bb = bollinger_bands(price, window=20, k=2.0)
print("\n[指标] 技术指标计算完成:")
print(f" SMA20 — 首个有效值日期: {sma20.first_valid_index().date()}")
print(f" SMA60 — 首个有效值日期: {sma60.first_valid_index().date()}")
print(f" RSI14 — 首个有效值日期: {rsi14.first_valid_index().date()}")
print(f" MACD — 首个有效值日期: {macd_df['macd'].first_valid_index().date()}")
print(f" BollingerBands — 首个有效值日期: {bb['mid'].first_valid_index().date()}")
# =============================================================================
# SECTION 2: Strategy A — Dual Moving Average Crossover
# 策略 A — 双均线金叉/死叉策略
# -----------------------------------------------------------------------------
# One of the oldest and most intuitive trend-following strategies.
# 最古老也最直观的趋势跟随策略之一。
#
# Logic / 逻辑:
# Golden Cross (金叉): short MA crosses ABOVE long MA → BUY (做多)
# Death Cross (死叉): short MA crosses BELOW long MA → SELL (平仓)
#
# Rationale / 原理:
# When the short-term average rises above the long-term average, it signals
# that recent momentum is stronger than the historical trend → bullish.
# 短期均线上穿长期均线,意味着近期动能强于历史趋势 → 看涨。
#
# Parameters / 参数:
# SHORT_WINDOW = 20 (fast line / 快线)
# LONG_WINDOW = 60 (slow line / 慢线)
# =============================================================================
SHORT_WIN = 20 # 短期均线窗口 / short-term MA window
LONG_WIN = 60 # 长期均线窗口 / long-term MA window
ma_short = sma(price, SHORT_WIN)
ma_long = sma(price, LONG_WIN)
# ── Signal generation 信号生成 ───────────────────────────────────────────────
#
# Signal (信号) = +1 when we should be LONG (持多仓), 0 when out of market (空仓)
#
# Step 1: raw_signal = 1 whenever short MA > long MA (short MA above long MA)
# Step 2: detect crossovers (cross = today's signal ≠ yesterday's signal)
#
# We use a "position" approach — hold the position until it reverses.
# 使用"持仓"方式 — 持有直到信号翻转。
# raw_signal: 1 = short above long (看多区域), 0 = short below long (看空区域)
raw_signal = (ma_short > ma_long).astype(int)
# Align signals: use yesterday's signal to trade today (avoid lookahead bias)
# 用昨天的信号决定今天的仓位,避免"未来数据偷窥" (前视偏差 / lookahead bias)
ma_signal = raw_signal.shift(1).fillna(0)
print("\n[策略A] 双均线信号生成完成")
print(f" 多头持仓天数 (Signal=1): {int(ma_signal.sum())}")
print(f" 空仓天数 (Signal=0): {int((ma_signal == 0).sum())}")
# =============================================================================
# SECTION 3: Strategy B — RSI Mean Reversion
# 策略 B — RSI 均值回归策略
# -----------------------------------------------------------------------------
# This is a contrarian strategy: buy when the market seems "too weak",
# sell when it seems "too strong".
# 这是一个逆势策略:市场"跌过头"时买入,"涨过头"时卖出。
#
# Logic / 逻辑:
# RSI drops below oversold level (超卖线, default 30) → BUY signal
# RSI rises above overbought level (超买线, default 70) → SELL signal
#
# This exploits mean reversion (均值回归): extreme prices tend to revert.
# 利用均值回归特性:极端价格倾向于回归均值。
#
# Risk / 风险:
# In a strong trend, RSI can stay oversold/overbought for long stretches.
# 在强趋势中RSI 可以长时间停留在超卖/超买区域,造成连续亏损。
# =============================================================================
RSI_OVERSOLD = 30 # 超卖线 / oversold threshold
RSI_OVERBOUGHT = 70 # 超买线 / overbought threshold
def rsi_signal(rsi_series: pd.Series,
oversold: float = 30,
overbought: float = 70) -> pd.Series:
"""
Generate long/short/flat signals from RSI.
根据 RSI 生成多空平信号
Returns a Series of:
+1 Long (做多)
-1 Short (做空)
0 Flat (空仓, no position)
"""
position = pd.Series(0, index=rsi_series.index, dtype=float)
current_pos = 0 # 当前持仓状态 / current position state
for i in range(1, len(rsi_series)):
r = rsi_series.iloc[i]
if pd.isna(r):
position.iloc[i] = 0
continue
# Entry rules / 入场规则
if r < oversold and current_pos == 0:
current_pos = 1 # 超卖 → 做多 / oversold → go long
elif r > overbought and current_pos == 0:
current_pos = -1 # 超买 → 做空 / overbought → go short
# Exit rules / 出场规则
# Exit long when RSI recovers above 50 (回到中性区域 / back to neutral)
elif current_pos == 1 and r > 50:
current_pos = 0
# Exit short when RSI falls below 50
elif current_pos == -1 and r < 50:
current_pos = 0
position.iloc[i] = current_pos
return position
rsi_pos = rsi_signal(rsi14, RSI_OVERSOLD, RSI_OVERBOUGHT)
# Shift by 1 day to avoid lookahead bias / 前移一天避免前视偏差
rsi_signal_shifted = rsi_pos.shift(1).fillna(0)
print("\n[策略B] RSI信号生成完成")
print(f" 多头持仓天数 (Signal=+1): {int((rsi_signal_shifted == 1).sum())}")
print(f" 空头持仓天数 (Signal=-1): {int((rsi_signal_shifted == -1).sum())}")
print(f" 空仓天数 (Signal= 0): {int((rsi_signal_shifted == 0).sum())}")
# =============================================================================
# SECTION 4: Vectorized Backtest Engine 向量化回测引擎
# -----------------------------------------------------------------------------
# A backtest (回测) simulates how a strategy would have performed
# on historical data. It is the primary tool for validating a strategy
# before risking real money.
# 回测是在历史数据上模拟策略表现的工具,是真实投资前验证策略的主要手段。
#
# Two main backtest styles / 两种主要回测方式:
#
# ① Vectorized backtest 向量化回测
# - Compute all positions & P&L as array operations at once (numpy/pandas)
# - Very fast; good for strategy exploration
# - 所有仓位和盈亏一次性用数组运算计算,速度极快,适合策略探索
#
# ② Event-driven backtest 事件驱动回测
# - Simulate time step-by-step, reacting to each market event
# - More realistic (handles fills, slippage, latency, order queuing)
# - 逐笔模拟市场事件,更真实(考虑成交、滑点、延迟等),速度较慢
#
# We use the vectorized approach here for clarity and speed.
# 此处使用向量化方式,兼顾清晰度和速度。
#
# Cost model 交易成本模型:
# - Commission (佣金): charged each time you trade (per trade)
# - Slippage (滑点): the difference between the expected fill price and
# the actual fill price (price moves against you)
# We approximate both as a percentage of the trade value.
# 两者合并近似为交易金额的固定比例。
# =============================================================================
class VectorizedBacktester:
"""
A simple vectorized backtesting engine.
简单的向量化回测引擎
Assumptions / 假设:
Long-only or long/short positions
Trade at next-day's open (用下一天开盘价成交) — conservative assumption
We approximate this by using the same day's close shifted by 1 day.
Round-trip cost (单次交易成本) = 2 × cost_per_trade
(pay cost on entry AND exit / 进出各收一次)
No leverage (无杠杆), position size is 100% of capital when in trade
"""
def __init__(
self,
prices: pd.Series,
signal: pd.Series,
cost_per_trade: float = 0.001, # 0.1% one-way / 单向 0.1% (含佣金+滑点)
initial_capital: float = 1_000_000.0, # 初始资金 / initial capital
name: str = "Strategy",
):
self.prices = prices
self.signal = signal.reindex(prices.index).fillna(0)
self.cost_per_trade = cost_per_trade
self.initial_capital = initial_capital
self.name = name
self._run()
def _run(self):
"""Core backtesting logic. 核心回测逻辑。"""
prices = self.prices
signal = self.signal
# ── Daily price return 日收益率 ────────────────────────────────────
daily_ret = prices.pct_change().fillna(0)
# ── Strategy return (before costs) 策略日收益率(扣除成本前)─────────
# Strategy return = signal × market return
# 策略当日收益率 = 持仓方向 × 市场当日收益率
strat_ret_gross = signal * daily_ret
# ── Transaction cost 交易成本 ──────────────────────────────────────
# Detect position changes (signal changes from one day to the next)
# 检测仓位变化(信号从一天到下一天发生变化)
position_change = signal.diff().fillna(0).abs() # >0 means we traded
# Cost is charged each time position changes
# 每次仓位变化时扣除成本
cost = position_change * self.cost_per_trade
# ── Net strategy return 策略净收益率 ───────────────────────────────
strat_ret_net = strat_ret_gross - cost
# ── Equity curve 净值曲线 ───────────────────────────────────────────
# The equity curve tracks how 1 unit of capital grows over time.
# 净值曲线追踪单位资本随时间的增长。
# (1 + daily_net_return) compounded every day
equity = self.initial_capital * (1 + strat_ret_net).cumprod()
equity_bh = self.initial_capital * (1 + daily_ret).cumprod() # Buy & Hold benchmark
# ── Drawdown 回撤 ──────────────────────────────────────────────────
# Drawdown measures how far we are from the peak at any point in time.
# 回撤衡量当前净值距离历史最高点的跌幅。
rolling_max = equity.cummax()
drawdown = (equity - rolling_max) / rolling_max # always <= 0
# Store results for later analysis
self.daily_ret = daily_ret
self.strat_ret = strat_ret_net
self.equity = equity
self.equity_bh = equity_bh
self.drawdown = drawdown
self.n_trades = int((position_change > 0).sum())
self.total_cost = cost.sum()
# ── Performance metrics 绩效指标 ──────────────────────────────────────────
#
# A well-rounded strategy evaluation uses multiple metrics, because
# no single number captures the full picture.
# 全面的策略评估需要多个指标,因为单一数字无法描述全貌。
#
# Key metrics / 关键指标:
# Total Return 总收益率 — how much did we make in total?
# CAGR 年化复合增长率 — annualized compounded growth rate
# Sharpe Ratio 夏普比率 — return per unit of total risk (risk-adjusted)
# Sortino Ratio 索提诺比率 — return per unit of DOWNSIDE risk only
# Max Drawdown 最大回撤 — worst peak-to-trough decline
# Calmar Ratio 卡玛比率 — CAGR / Max Drawdown (reward vs worst loss)
# Win Rate 胜率 — fraction of days (or trades) with positive P&L
# Profit Factor 盈亏比 — total profit / total loss
def metrics(self) -> dict:
"""Compute and return a dictionary of performance metrics.
计算并返回绩效指标字典"""
r = self.strat_ret
eq = self.equity
n = len(r)
years = n / 252.0 # approximate years in sample / 样本年数估算
# Total return / 总收益率
total_return = (eq.iloc[-1] / self.initial_capital) - 1
# CAGR 年化复合增长率
# CAGR = (EndValue / StartValue)^(1/years) - 1
cagr = (1 + total_return) ** (1 / years) - 1
# Annualized volatility 年化波动率
ann_vol = r.std() * np.sqrt(252)
# Sharpe Ratio 夏普比率
# Sharpe = (Mean excess return) / StdDev(return) × √252
# Excess return = strategy return - risk-free rate
# 超额收益率 = 策略收益率 - 无风险利率
# We use 0 as risk-free rate for simplicity (or assume it's netted out)
risk_free = 0.0
sharpe = (r.mean() - risk_free / 252) / r.std() * np.sqrt(252) if r.std() > 0 else 0
# Sortino Ratio 索提诺比率
# Like Sharpe but only penalizes DOWNSIDE volatility
# 类似夏普,但只惩罚下行波动率(亏损波动率)
downside = r[r < 0]
downside_std = downside.std() * np.sqrt(252) if len(downside) > 0 else 1e-9
sortino = (cagr - risk_free) / downside_std if downside_std > 0 else 0
# Maximum Drawdown 最大回撤
max_dd = self.drawdown.min() # most negative value (最大负值)
# Calmar Ratio 卡玛比率
# Calmar = CAGR / |Max Drawdown|
calmar = cagr / abs(max_dd) if max_dd != 0 else 0
# Win rate 胜率 (fraction of trading days with positive return)
win_rate = (r > 0).mean()
# Profit factor 盈亏比
# = Sum of positive returns / |Sum of negative returns|
gross_profit = r[r > 0].sum()
gross_loss = abs(r[r < 0].sum())
profit_factor = gross_profit / gross_loss if gross_loss > 0 else np.inf
return {
"总收益率 Total Return": f"{total_return:.2%}",
"年化收益率 CAGR": f"{cagr:.2%}",
"年化波动率 Ann. Volatility": f"{ann_vol:.2%}",
"夏普比率 Sharpe Ratio": f"{sharpe:.3f}",
"索提诺比率 Sortino Ratio": f"{sortino:.3f}",
"最大回撤 Max Drawdown": f"{max_dd:.2%}",
"卡玛比率 Calmar Ratio": f"{calmar:.3f}",
"胜率 Win Rate": f"{win_rate:.2%}",
"盈亏比 Profit Factor": f"{profit_factor:.3f}",
"交易次数 # Trades": str(self.n_trades),
"总成本 Total Cost": f"{self.total_cost:.4%}",
}
def print_metrics(self):
"""Pretty-print the performance report. 格式化打印绩效报告。"""
print(f"\n{'=' * 55}")
print(f" 策略绩效报告 / Performance Report: {self.name}")
print(f"{'=' * 55}")
for k, v in self.metrics().items():
print(f" {k:<35} {v}")
print(f"{'=' * 55}")
# =============================================================================
# SECTION 5: Run Backtests 执行回测
# =============================================================================
# ── Strategy A: MA Crossover 双均线策略 ──────────────────────────────────────
bt_ma = VectorizedBacktester(
prices=price,
signal=ma_signal, # +1 = long, 0 = flat
cost_per_trade=0.001, # 0.1% per trade (reasonable for liquid stocks)
name="双均线策略 (MA Crossover 20/60)",
)
# ── Strategy B: RSI Mean Reversion RSI均值回归策略 ──────────────────────────
bt_rsi = VectorizedBacktester(
prices=price,
signal=rsi_signal_shifted, # +1 = long, -1 = short, 0 = flat
cost_per_trade=0.001,
name="RSI均值回归策略 (RSI Mean Reversion 14)",
)
# ── Benchmark: Buy & Hold 基准:买入并持有 ───────────────────────────────────
# Buy & Hold (买入持有) is always our benchmark: simply hold the asset forever.
# It requires zero skill and zero effort — any strategy must beat this to
# justify the extra complexity and transaction costs.
# 买入持有是永远的基准策略:无需技能、零成本。任何策略都必须超越它才有意义。
bt_bh = VectorizedBacktester(
prices=price,
signal=pd.Series(1, index=price.index, dtype=float), # always long / 始终做多
cost_per_trade=0.0, # no trading costs / 无交易成本
name="Buy & Hold 基准 (买入持有)",
)
bt_ma.print_metrics()
bt_rsi.print_metrics()
bt_bh.print_metrics()
# =============================================================================
# SECTION 6: Visualization 可视化
# =============================================================================
fig = plt.figure(figsize=(16, 22))
gs = gridspec.GridSpec(6, 2, figure=fig, hspace=0.45, wspace=0.3)
# ── Plot 1: Price + MA signals 价格 + 均线信号 ────────────────────────────────
ax1 = fig.add_subplot(gs[0, :]) # span full width
ax1.plot(price, color="#1f77b4", linewidth=1, label="价格 Price")
ax1.plot(ma_short, color="orange", linewidth=1.2, label=f"SMA{SHORT_WIN} (快线)")
ax1.plot(ma_long, color="red", linewidth=1.2, label=f"SMA{LONG_WIN} (慢线)")
# Shade long periods (持多仓的区间着色)
ax1.fill_between(
price.index, price.min(), price.max(),
where=(ma_signal == 1).values,
alpha=0.12, color="green", label="多头持仓区间 Long Period"
)
ax1.set_title("策略A — 双均线信号 (MA Crossover Signals)", fontsize=13, fontweight="bold")
ax1.legend(loc="upper left", fontsize=8)
ax1.set_ylabel("价格 Price")
ax1.grid(alpha=0.3)
# ── Plot 2: RSI RSI指标 ────────────────────────────────────────────────────
ax2 = fig.add_subplot(gs[1, :])
ax2.plot(rsi14, color="purple", linewidth=1)
ax2.axhline(RSI_OVERBOUGHT, color="red", linestyle="--", linewidth=1, label=f"超买线 {RSI_OVERBOUGHT}")
ax2.axhline(RSI_OVERSOLD, color="green", linestyle="--", linewidth=1, label=f"超卖线 {RSI_OVERSOLD}")
ax2.axhline(50, color="gray", linestyle=":", linewidth=0.8)
ax2.fill_between(rsi14.index, RSI_OVERSOLD, rsi14,
where=(rsi14 < RSI_OVERSOLD), alpha=0.25, color="green",
label="超卖区域 Oversold")
ax2.fill_between(rsi14.index, rsi14, RSI_OVERBOUGHT,
where=(rsi14 > RSI_OVERBOUGHT), alpha=0.25, color="red",
label="超买区域 Overbought")
ax2.set_ylim(0, 100)
ax2.set_title(f"策略B指标 — RSI({14}) 均值回归信号 (RSI Mean Reversion)", fontsize=13, fontweight="bold")
ax2.set_ylabel("RSI")
ax2.legend(loc="upper left", fontsize=8, ncol=2)
ax2.grid(alpha=0.3)
# ── Plot 3: Bollinger Bands 布林带 ────────────────────────────────────────────
ax3 = fig.add_subplot(gs[2, :])
ax3.plot(price, color="#1f77b4", linewidth=1, label="价格 Price")
ax3.plot(bb["mid"], color="orange", linewidth=1.2, label="中轨 Middle (SMA20)")
ax3.plot(bb["upper"],color="red", linewidth=1, linestyle="--", label="上轨 Upper (+2σ)")
ax3.plot(bb["lower"],color="green", linewidth=1, linestyle="--", label="下轨 Lower (-2σ)")
ax3.fill_between(price.index, bb["upper"], bb["lower"], alpha=0.07, color="blue")
ax3.set_title("布林带 (Bollinger Bands 20, 2σ)", fontsize=13, fontweight="bold")
ax3.set_ylabel("价格 Price")
ax3.legend(loc="upper left", fontsize=8, ncol=2)
ax3.grid(alpha=0.3)
# ── Plot 4: MACD MACD指标 ────────────────────────────────────────────────────
ax4 = fig.add_subplot(gs[3, :])
ax4.plot(macd_df["macd"], color="blue", linewidth=1, label="MACD线 (DIF)")
ax4.plot(macd_df["signal"], color="orange", linewidth=1, label="信号线 (DEA)")
colors = ["green" if v >= 0 else "red" for v in macd_df["histogram"]]
ax4.bar(macd_df.index, macd_df["histogram"], color=colors, alpha=0.5, width=1, label="柱状图 Histogram")
ax4.axhline(0, color="black", linewidth=0.8)
ax4.set_title("MACD (12/26/9) — 趋势确认指标 (Trend Confirmation)", fontsize=13, fontweight="bold")
ax4.set_ylabel("MACD")
ax4.legend(loc="upper left", fontsize=8)
ax4.grid(alpha=0.3)
# ── Plot 5: Equity Curves 净值曲线 ────────────────────────────────────────────
ax5 = fig.add_subplot(gs[4, :])
ax5.plot(bt_ma.equity, color="blue", linewidth=1.5, label="策略A: 双均线 MA Crossover")
ax5.plot(bt_rsi.equity, color="purple", linewidth=1.5, label="策略B: RSI 均值回归 RSI Reversion")
ax5.plot(bt_bh.equity, color="gray", linewidth=1.2, linestyle="--", label="基准: 买入持有 Buy & Hold")
ax5.set_title("净值曲线对比 (Equity Curve Comparison)", fontsize=13, fontweight="bold")
ax5.set_ylabel("账户价值 Portfolio Value (元)")
ax5.legend(loc="upper left", fontsize=9)
ax5.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"¥{x/1e4:.0f}"))
ax5.grid(alpha=0.3)
# ── Plot 6: Drawdown 回撤曲线 ────────────────────────────────────────────────
ax6 = fig.add_subplot(gs[5, :])
ax6.fill_between(bt_ma.drawdown.index, bt_ma.drawdown, 0, alpha=0.5, color="blue", label="策略A")
ax6.fill_between(bt_rsi.drawdown.index, bt_rsi.drawdown, 0, alpha=0.5, color="purple", label="策略B")
ax6.fill_between(bt_bh.drawdown.index, bt_bh.drawdown, 0, alpha=0.3, color="gray", label="Buy & Hold")
ax6.set_title("回撤曲线 (Drawdown Curves)", fontsize=13, fontweight="bold")
ax6.set_ylabel("回撤幅度 Drawdown")
ax6.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:.0%}"))
ax6.legend(loc="lower left", fontsize=9)
ax6.grid(alpha=0.3)
plt.suptitle(
"量化交易策略开发与回测演示\nQuantitative Trading: Strategy Development & Backtesting",
fontsize=15, fontweight="bold", y=1.005,
)
plt.savefig("strategy_backtest_demo.png", dpi=120, bbox_inches="tight")
plt.show()
print("\n[图表] 已保存至 strategy_backtest_demo.png")
# =============================================================================
# SECTION 7: Walk-Forward Validation 滚动前向验证
# -----------------------------------------------------------------------------
# A critical warning for all new quant traders / 对所有量化新手的重要警告:
#
# In-sample overfitting (样本内过拟合) is the #1 trap in backtesting.
# 样本内过拟合是回测中最大的陷阱。
#
# If you test 100 different parameter sets on the same data and pick the best,
# that "best" result will almost certainly NOT hold out of sample.
# 如果在同一份数据上测试100组参数并选最好的这个"最优"结果在样本外几乎必然失效。
# This is called data snooping bias / 数据窥探偏差 or p-hacking.
#
# Walk-Forward Validation (滚动前向验证) helps guard against this:
# ┌──────────────────────────────────────────────────────────────────────┐
# │ Window 1: [TRAIN period 1] → optimize params → TEST on period 1+ │
# │ Window 2: [TRAIN period 2] → optimize params → TEST on period 2+ │
# │ …repeat, always training on past, testing on future │
# │ 始终用过去数据训练,用未来数据测试 │
# └──────────────────────────────────────────────────────────────────────┘
# Only report the concatenated OUT-OF-SAMPLE test results.
# 只汇报样本外OOS的测试结果。
#
# Below: a simplified version — we just split into train / test (80/20).
# 下面是简化版:直接按 80/20 切分训练集和测试集。
# =============================================================================
TRAIN_RATIO = 0.8
split_idx = int(len(price) * TRAIN_RATIO)
split_date = price.index[split_idx]
price_train = price.iloc[:split_idx]
price_test = price.iloc[split_idx:]
print(f"\n{'=' * 55}")
print(f" 滚动前向验证 / Walk-Forward Split")
print(f"{'=' * 55}")
print(f" 训练期 Train: {price_train.index[0].date()}{price_train.index[-1].date()} ({len(price_train)} 天)")
print(f" 测试期 Test : {price_test.index[0].date()}{price_test.index[-1].date()} ({len(price_test)} 天)")
# ── Optimize MA windows on TRAIN set 在训练集上优化均线参数 ────────────────────
#
# Grid search (网格搜索): try all combinations in the parameter space.
# This is the simplest optimization method — good for small parameter spaces.
# 网格搜索:遍历参数空间内的所有组合。适合参数空间小的情形。
print("\n[优化] 在训练集上搜索最优均线参数...")
print(" (搜索空间: short=[5,10,15,20,30], long=[30,40,50,60,80,100])")
best_sharpe = -np.inf
best_short = SHORT_WIN
best_long = LONG_WIN
results_grid = []
for sw in [5, 10, 15, 20, 30]:
for lw in [30, 40, 50, 60, 80, 100]:
if sw >= lw:
continue # short must be shorter than long / 短期必须小于长期
ma_s = sma(price_train, sw)
ma_l = sma(price_train, lw)
sig = (ma_s > ma_l).astype(int).shift(1).fillna(0)
bt = VectorizedBacktester(price_train, sig, cost_per_trade=0.001, name="grid")
m = bt.metrics()
sharpe_val = float(m["夏普比率 Sharpe Ratio"])
results_grid.append({"short": sw, "long": lw, "sharpe": sharpe_val})
if sharpe_val > best_sharpe:
best_sharpe = sharpe_val
best_short = sw
best_long = lw
print(f"\n 最优参数 (训练集 in-sample): short={best_short}, long={best_long}")
print(f" 训练集夏普比率 In-sample Sharpe: {best_sharpe:.3f}")
# ── Apply best params on TEST set 将最优参数应用于测试集 ──────────────────────
ma_s_test = sma(price_test, best_short)
ma_l_test = sma(price_test, best_long)
sig_test = (ma_s_test > ma_l_test).astype(int).shift(1).fillna(0)
bt_test = VectorizedBacktester(price_test, sig_test, cost_per_trade=0.001,
name=f"MA({best_short}/{best_long}) — 测试集 OOS")
bt_test.print_metrics()
print("\n" + "=" * 55)
print(" ⚠️ 注意 / WARNING:")
print(" 训练集(in-sample)夏普 通常高于 测试集(out-of-sample)夏普")
print(" In-sample Sharpe is typically HIGHER than out-of-sample.")
print(" 夏普衰减 (Sharpe decay) 是策略过拟合的典型信号。")
print(" Sharpe decay is a classic sign of overfitting.")
print("=" * 55)
# =============================================================================
# SECTION 8: Return Distribution Analysis 收益率分布分析
# -----------------------------------------------------------------------------
# Before trusting your Sharpe ratio, check if the return distribution
# violates the normality assumption.
# 在相信夏普比率之前,检验收益率分布是否违背正态假设。
#
# Real returns typically show:
# 真实收益率通常呈现:
# Fat tails (厚尾 / leptokurtosis): extreme events more frequent than normal
# Negative skew (负偏态): crashes are larger than rallies
#
# A high Sharpe ratio on a fat-tailed distribution can be misleading.
# 厚尾分布下的高夏普比率可能具有误导性。
# =============================================================================
fig2, axes = plt.subplots(1, 2, figsize=(14, 5))
# ── Return distribution histogram 收益率直方图 ─────────────────────────────
ax = axes[0]
r_ma = bt_ma.strat_ret.dropna()
r_bh = bt_bh.strat_ret.dropna()
ax.hist(r_bh, bins=80, alpha=0.5, color="gray", density=True, label="Buy & Hold")
ax.hist(r_ma, bins=80, alpha=0.5, color="blue", density=True, label="策略A: MA Crossover")
# Overlay a normal distribution for comparison / 叠加正态分布对比
x_range = np.linspace(r_bh.min(), r_bh.max(), 300)
ax.plot(x_range, stats.norm.pdf(x_range, r_bh.mean(), r_bh.std()),
color="red", linewidth=1.5, linestyle="--", label="正态分布 Normal Dist.")
ax.set_title("收益率分布 (Return Distribution)", fontsize=12)
ax.set_xlabel("日收益率 Daily Return")
ax.set_ylabel("频率密度 Density")
ax.legend(fontsize=8)
ax.grid(alpha=0.3)
# Print distribution stats
print(f"\n[分布] 策略A — 日收益率统计:")
print(f" 偏度 Skewness : {r_ma.skew():.3f} (负值=左尾更厚 fat left tail)")
print(f" 峰度 Kurtosis : {r_ma.kurtosis():.3f} (>0 表示厚尾 fat tails vs normal)")
# ── Monthly returns heatmap 月度收益热力图 ─────────────────────────────────
ax = axes[1]
monthly = bt_ma.strat_ret.resample("M").apply(lambda x: (1 + x).prod() - 1)
monthly_df = pd.DataFrame({
"year": monthly.index.year,
"month": monthly.index.month,
"ret": monthly.values,
})
pivot = monthly_df.pivot(index="year", columns="month", values="ret")
pivot.columns = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
import matplotlib.colors as mcolors
cmap = mcolors.LinearSegmentedColormap.from_list("rg", ["#d73027","#ffffff","#1a9850"])
im = ax.imshow(pivot.values, cmap=cmap, aspect="auto",
vmin=-0.15, vmax=0.15)
ax.set_xticks(range(12))
ax.set_xticklabels(pivot.columns, fontsize=8)
ax.set_yticks(range(len(pivot.index)))
ax.set_yticklabels(pivot.index, fontsize=9)
for i in range(len(pivot.index)):
for j in range(12):
v = pivot.values[i, j]
if not np.isnan(v):
ax.text(j, i, f"{v:.1%}", ha="center", va="center", fontsize=6,
color="black" if abs(v) < 0.08 else "white")
ax.set_title("策略A月度收益热力图\n(Monthly Return Heatmap)", fontsize=11)
plt.colorbar(im, ax=ax, format=plt.FuncFormatter(lambda x, _: f"{x:.0%}"))
plt.tight_layout()
plt.savefig("return_distribution.png", dpi=120, bbox_inches="tight")
plt.show()
print("[图表] 已保存至 return_distribution.png")
# =============================================================================
# SECTION 9: Summary & Next Steps 总结与后续
# =============================================================================
print(f"""
{'=' * 70}
总结 Summary
{'=' * 70}
Demo 演示了量化策略开发与回测的完整流程
技术指标计算 Technical Indicators
SMA / EMA / RSI / MACD / Bollinger Bands
信号生成 Signal Generation
策略A: 双均线金叉死叉 (MA Crossover) 趋势跟随
策略B: RSI 超买超卖 (RSI Reversion) 均值回归
向量化回测引擎 Vectorized Backtester
考虑了交易成本(佣金+滑点)和前视偏差(lookahead bias)
绩效指标 Performance Metrics
Sharpe / Sortino / Max Drawdown / Calmar / Win Rate / Profit Factor
前向验证 Walk-Forward Validation
训练集优化参数 测试集验证 防止过拟合
收益率分布 Return Distribution
偏度/峰度检验月度热力图
下一步学习方向 Next Steps:
因子选股策略 (Alpha Factor Models) Fama-French, Momentum
组合优化 (Portfolio Optimization) Mean-Variance, Risk Parity
事件驱动回测 (Event-Driven Backtesting) more realistic execution
机器学习信号 (ML-based Signals) XGBoost, LSTM for return prediction
风险管理 (Risk Management) Position sizing, Stop-loss, VaR
{'=' * 70}
""")

BIN
return_distribution.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
strategy_backtest_demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB