diff --git a/doc_01_data_pipeline.md b/doc_01_data_pipeline.md new file mode 100644 index 0000000..dec29d5 --- /dev/null +++ b/doc_01_data_pipeline.md @@ -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)* diff --git a/doc_02_strategy_backtest.md b/doc_02_strategy_backtest.md new file mode 100644 index 0000000..99f6703 --- /dev/null +++ b/doc_02_strategy_backtest.md @@ -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. [策略 B:RSI 均值回归](#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. 策略 B:RSI 均值回归 (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)* diff --git a/doc_03_event_driven_backtest.md b/doc_03_event_driven_backtest.md new file mode 100644 index 0000000..9b185df --- /dev/null +++ b/doc_03_event_driven_backtest.md @@ -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大组件、成本模型、与向量化对比 | diff --git a/event_driven_backtest.png b/event_driven_backtest.png new file mode 100644 index 0000000..a758872 Binary files /dev/null and b/event_driven_backtest.png differ diff --git a/quant_event_driven_backtest_demo.py b/quant_event_driven_backtest_demo.py new file mode 100644 index 0000000..1cefd4f --- /dev/null +++ b/quant_event_driven_backtest_demo.py @@ -0,0 +1,1531 @@ +# ============================================================================= +# Quantitative Trading — Event-Driven Backtest Engine +# 量化交易 — 事件驱动回测引擎 +# ============================================================================= +# +# 本文件是策略开发演示 (quant_strategy_backtest_demo.py) 的续集。 +# This file is the sequel to the strategy development demo. +# +# ───────────────────────────────────────────────────────────────────────────── +# Why Event-Driven? 为什么要用事件驱动? +# ───────────────────────────────────────────────────────────────────────────── +# The vectorized backtest in the previous demo computed everything at once +# using array operations. This is fast, but it has a fundamental problem: +# 上一个演示中的向量化回测一次性用数组运算计算所有结果。速度很快,但有根本缺陷: +# +# It cannot easily model: +# 难以建模: +# • Different order types 不同订单类型 (limit / stop / market orders) +# • Partial fills 部分成交 +# • Intraday price path 日内价格路径 (open → high/low → close sequence) +# • Real-time risk checks 实时风控检查 (e.g. position limits, margin calls) +# • Multiple strategies 多策略组合 +# • Portfolio-level rules 组合层面规则 (e.g. max sector exposure) +# +# The event-driven approach treats the backtest as a simulation of the actual +# live trading system. The same strategy & portfolio code can run in both +# backtest mode AND live trading mode — just swap the data source. +# +# 事件驱动方法将回测视为真实交易系统的模拟。 +# 同一套策略和组合代码可以在回测模式和实盘模式下运行——只需替换数据源。 +# +# ───────────────────────────────────────────────────────────────────────────── +# Architecture Overview 架构总览 +# ───────────────────────────────────────────────────────────────────────────── +# +# ┌─────────────────────────────────┐ +# │ Event Queue 事件队列 │ +# │ (FIFO deque — central bus) │ +# └───────────────┬─────────────────┘ +# │ events flow through +# ┌────────────────────┼────────────────────┐ +# ▼ ▼ ▼ +# ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ +# │ DataHandler │ │ Strategy │ │ Portfolio │ +# │ 数据处理器 │ │ 策略引擎 │ │ 组合管理器 │ +# │ │ │ │ │ │ +# │ Streams bars │ │ Reads market │ │ Receives order │ +# │ (OHLCV) into │ │ data, computes │ │ requests from │ +# │ the queue as │ │ signals, emits │ │ strategy and │ +# │ MarketEvents │ │ SignalEvents │ │ emits │ +# └────────────────┘ └────────────────┘ │ OrderEvents │ +# └───────┬────────┘ +# │ +# ┌───────▼────────┐ +# │ ExecutionHandler│ +# │ 执行处理器 │ +# │ (模拟券商) │ +# │ │ +# │ Fills orders, │ +# │ applies slippage│ +# │ & commission, │ +# │ emits │ +# │ FillEvents │ +# └────────────────┘ +# +# ───────────────────────────────────────────────────────────────────────────── +# Event flow for one trading day 单个交易日的事件流 +# ───────────────────────────────────────────────────────────────────────────── +# +# [Clock ticks to new day / 时钟滴答到新的一天] +# │ +# ▼ +# DataHandler emits MarketEvent (新K线数据到达) +# │ +# ▼ +# Strategy processes MarketEvent → emits SignalEvent (信号:买/卖/平) +# │ +# ▼ +# Portfolio processes SignalEvent → emits OrderEvent (下单:市价/限价/止损) +# │ +# ▼ +# ExecutionHandler processes OrderEvent → emits FillEvent (成交确认) +# │ +# ▼ +# Portfolio processes FillEvent → updates positions & P&L (更新持仓和盈亏) +# │ +# ▼ +# [repeat for next day / 重复下一天] +# +# ───────────────────────────────────────────────────────────────────────────── +# Topics covered / 涵盖主题: +# 1. Event Classes 事件类 (Market, Signal, Order, Fill) +# 2. DataHandler 数据处理器 +# 3. Strategy 策略 (MA Crossover & RSI) +# 4. Portfolio 组合管理器 (cash, positions, P&L) +# 5. ExecutionHandler 执行处理器 (slippage models, order types) +# 6. BacktestEngine 回测引擎 (main event loop) +# 7. Performance Analytics 绩效分析 +# 8. Comparison: Event-Driven vs Vectorized +# ============================================================================= + +from __future__ import annotations + +import queue # Python built-in FIFO queue +import enum # for clean event type constants +from dataclasses import dataclass, field +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Tuple +from copy import deepcopy + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import matplotlib.gridspec as gridspec +import warnings + +warnings.filterwarnings('ignore') + +# ── Chinese font / 中文字体 ────────────────────────────────────────────────── +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("=" * 72) +print(" 量化交易 — 事件驱动回测引擎") +print(" Quantitative Trading — Event-Driven Backtest Engine") +print("=" * 72) + + +# ============================================================================= +# SECTION 1: Event Classes 事件类 +# ============================================================================= +# +# Every piece of information that flows through the system is wrapped in an +# Event object. This decouples the components — a Strategy doesn't call the +# Portfolio directly; it just puts a SignalEvent on the queue. +# +# 系统中流通的每一条信息都被包装成一个 Event 对象。 +# 这使各组件解耦——策略不直接调用组合,只是把 SignalEvent 放入队列。 +# +# Four event types: +# 四种事件类型: +# +# MarketEvent 市场事件 — new OHLCV bar has arrived from the data feed +# SignalEvent 信号事件 — strategy has decided to buy or sell +# OrderEvent 订单事件 — portfolio has sized the trade and placed an order +# FillEvent 成交事件 — broker has confirmed the order was executed +# ============================================================================= + +class EventType(enum.Enum): + """ + Enumeration of all event types in the system. + 系统中所有事件类型的枚举。 + """ + MARKET = "MARKET" # 市场事件 — new price bar + SIGNAL = "SIGNAL" # 信号事件 — trade signal from strategy + ORDER = "ORDER" # 订单事件 — order from portfolio + FILL = "FILL" # 成交事件 — filled order from broker + + +class Direction(enum.Enum): + """ + Trade direction. 交易方向。 + """ + LONG = "LONG" # 做多 / buy + SHORT = "SHORT" # 做空 / sell short + EXIT = "EXIT" # 平仓 / close position + + +class OrderType(enum.Enum): + """ + Order types. 订单类型。 + + MARKET 市价单 — execute immediately at the best available price + 立即以市场最优价格成交,确保成交但价格不确定 + LIMIT 限价单 — execute only at a specified price or better + 只在指定价格或更优价格成交,价格确定但不保证成交 + STOP 止损单 — becomes a market order when price hits the stop level + 价格触及止损价时转变为市价单,用于控制最大亏损 + """ + MARKET = "MARKET" + LIMIT = "LIMIT" + STOP = "STOP" + + +@dataclass +class MarketEvent: + """ + Triggered when the DataHandler has a new bar of OHLCV data ready. + 当数据处理器有新的 OHLCV K线数据就绪时触发。 + + This is the "heartbeat" of the system — it drives all other components. + 这是系统的"心跳"——驱动所有其他组件。 + + Fields / 字段: + symbol 股票代码 — which ticker this bar belongs to + date 日期 — the date of this bar + open 开盘价 — first trade of the session + high 最高价 — highest trade during the session + low 最低价 — lowest trade during the session + close 收盘价 — last trade of the session + volume 成交量 — total shares traded + """ + type : EventType = field(default=EventType.MARKET, init=False) + symbol : str = "" + date : pd.Timestamp = None + open : float = 0.0 + high : float = 0.0 + low : float = 0.0 + close : float = 0.0 + volume : float = 0.0 + + def __repr__(self): + return (f"MarketEvent({self.symbol} {self.date.date()} " + f"O={self.open:.2f} H={self.high:.2f} " + f"L={self.low:.2f} C={self.close:.2f})") + + +@dataclass +class SignalEvent: + """ + Generated by a Strategy when it detects a trade opportunity. + 策略检测到交易机会时生成。 + + A SignalEvent is a "suggestion" — the Portfolio decides whether and + how much to actually trade based on its own risk rules. + 信号事件是一个"建议"——组合管理器根据自身风控规则决定是否以及交易多少。 + + Fields / 字段: + symbol 股票代码 + date 信号产生日期 + direction 方向: LONG(做多) / SHORT(做空) / EXIT(平仓) + strength 信号强度 [0, 1] — used for position sizing (仓位管理) + 1.0 = full conviction, 0.5 = half conviction + strategy 策略名称 — useful when running multiple strategies + """ + type : EventType = field(default=EventType.SIGNAL, init=False) + symbol : str = "" + date : pd.Timestamp = None + direction : Direction = Direction.LONG + strength : float = 1.0 # 信号强度 / signal strength for position sizing + strategy : str = "" + + def __repr__(self): + return (f"SignalEvent({self.symbol} {self.date.date()} " + f"{self.direction.value} strength={self.strength:.2f})") + + +@dataclass +class OrderEvent: + """ + Sent from Portfolio to ExecutionHandler to place a trade. + 从组合管理器发送给执行处理器以下单。 + + This is a concrete order: it specifies WHAT to trade and HOW MUCH. + 这是一个具体订单:指定交易什么以及交易多少。 + + Fields / 字段: + symbol 股票代码 + date 下单日期 + order_type 订单类型 MARKET / LIMIT / STOP + direction 方向 LONG / SHORT / EXIT + quantity 数量 number of shares (股数) + limit_price 限价 for LIMIT orders (限价单价格) + stop_price 止损价 for STOP orders (止损单价格) + """ + type : EventType = field(default=EventType.ORDER, init=False) + symbol : str = "" + date : pd.Timestamp = None + order_type : OrderType = OrderType.MARKET + direction : Direction = Direction.LONG + quantity : int = 0 + limit_price : float = 0.0 + stop_price : float = 0.0 + + def __repr__(self): + return (f"OrderEvent({self.symbol} {self.date.date()} " + f"{self.order_type.value} {self.direction.value} " + f"qty={self.quantity})") + + +@dataclass +class FillEvent: + """ + Confirms that an order has been executed by the broker. + 确认订单已被券商执行(成交回报)。 + + A Fill is the ground truth — it records what ACTUALLY happened. + 成交回报是最终事实——记录实际发生了什么。 + + Fields / 字段: + symbol 股票代码 + date 成交日期 + direction 成交方向 LONG / SHORT / EXIT + quantity 成交数量 shares actually filled + fill_price 成交价格 actual execution price (含滑点 including slippage) + commission 佣金 brokerage commission + slippage 滑点 price impact of the trade + """ + type : EventType = field(default=EventType.FILL, init=False) + symbol : str = "" + date : pd.Timestamp = None + direction : Direction = Direction.LONG + quantity : int = 0 + fill_price : float = 0.0 + commission : float = 0.0 + slippage : float = 0.0 + + @property + def total_cost(self) -> float: + """ + Total cash impact of this fill. + 本次成交的总现金影响。 + + For a BUY (LONG): negative (cash decreases) 现金减少 + For a SELL (EXIT): positive (cash increases) 现金增加 + """ + sign = -1 if self.direction in (Direction.LONG, Direction.SHORT) else +1 + return sign * (self.fill_price * self.quantity + self.commission) + + def __repr__(self): + return (f"FillEvent({self.symbol} {self.date.date()} " + f"{self.direction.value} qty={self.quantity} " + f"@{self.fill_price:.2f} comm={self.commission:.2f})") + + +# ============================================================================= +# SECTION 2: DataHandler 数据处理器 +# ============================================================================= +# +# The DataHandler is responsible for feeding price data into the system +# one bar at a time. It simulates the reality of not knowing the future. +# +# 数据处理器负责逐根K线地向系统输入价格数据。 +# 它模拟了"无法预知未来"的现实。 +# +# The key design rule: +# 核心设计规则: +# +# ❌ WRONG: strategy sees bar[t] and trades on bar[t] (lookahead bias!) +# 策略看到 bar[t] 并在 bar[t] 交易(前视偏差!) +# +# ✅ RIGHT: strategy sees bar[t], generates signal based on bar[t], +# order fills at bar[t+1] open (next day open) +# 策略看到 bar[t],生成信号,订单在 bar[t+1] 的开盘价成交 +# +# This is why DataHandler, Strategy, Portfolio, and ExecutionHandler are +# separate — the separation naturally enforces the time boundary. +# 这就是为什么这四个组件分开——分离自然地强制了时间边界。 +# ============================================================================= + +class DataHandler: + """ + Streams historical OHLCV data bar by bar into the event queue. + 将历史 OHLCV 数据逐根K线地流入事件队列。 + + In live trading, this would connect to a real-time market data API. + 在实盘交易中,这里会连接实时行情 API。 + """ + + def __init__(self, data: pd.DataFrame, symbol: str, event_queue: queue.Queue): + """ + Parameters / 参数: + data — DataFrame with DatetimeIndex and columns: + 带 DatetimeIndex 的 DataFrame,列名为: + open, high, low, close, volume + symbol — ticker symbol / 股票代码 + event_queue — the shared event queue / 共享事件队列 + """ + self.data = data.copy() + self.symbol = symbol + self.event_queue = event_queue + self._dates = list(data.index) # all trading dates + self._cursor = 0 # current position in history + self._history = [] # bars seen so far (已观测的K线) + + @property + def current_date(self) -> Optional[pd.Timestamp]: + """The date of the most recently published bar. 最新已发布K线的日期。""" + if self._cursor == 0: + return None + return self._dates[self._cursor - 1] + + @property + def n_bars_seen(self) -> int: + """How many bars have been streamed so far. 到目前为止已流出多少根K线。""" + return self._cursor + + def has_more_data(self) -> bool: + """Returns True if there is at least one more bar to stream.""" + return self._cursor < len(self._dates) + + def stream_next(self) -> bool: + """ + Emit the next bar as a MarketEvent onto the queue. + 将下一根K线作为 MarketEvent 发送到队列。 + + Returns True if a bar was emitted, False if data is exhausted. + 如果发出了K线则返回 True,如果数据已耗尽则返回 False。 + """ + if not self.has_more_data(): + return False + + date = self._dates[self._cursor] + row = self.data.loc[date] + + # Build the MarketEvent for this bar / 构建本根K线的 MarketEvent + bar = MarketEvent( + symbol = self.symbol, + date = date, + open = float(row.get("open", row.get("close", 0))), + high = float(row.get("high", row.get("close", 0))), + low = float(row.get("low", row.get("close", 0))), + close = float(row["close"]), + volume = float(row.get("volume", 0)), + ) + + # Add to internal history (the strategy can query this) + # 添加到内部历史记录(策略可以查询) + self._history.append(bar) + self._cursor += 1 + + # Push onto the central event queue / 推送到中央事件队列 + self.event_queue.put(bar) + return True + + def get_history(self, n: Optional[int] = None) -> List[MarketEvent]: + """ + Return the last n bars seen so far (default: all history). + 返回迄今为止看到的最后 n 根K线(默认:全部历史)。 + + This is what the Strategy uses to compute indicators. + 这是策略用于计算技术指标的数据。 + """ + if n is None: + return self._history + return self._history[-n:] + + def get_close_series(self) -> pd.Series: + """ + Return a pd.Series of all close prices seen so far. + 返回到目前为止所有已观测收盘价的 pd.Series。 + + Useful for computing rolling indicators (移动平均等滚动指标). + """ + if not self._history: + return pd.Series(dtype=float) + return pd.Series( + [b.close for b in self._history], + index=[b.date for b in self._history], + ) + + +# ============================================================================= +# SECTION 3: Strategy (Abstract Base + Concrete Implementations) +# 策略(抽象基类 + 具体实现) +# ============================================================================= +# +# A Strategy: +# 1. Listens for MarketEvents +# 2. Computes indicators on the historical data it has seen so far +# 3. Generates SignalEvents when its rules are triggered +# +# 策略: +# 1. 监听 MarketEvent +# 2. 根据迄今为止看到的历史数据计算指标 +# 3. 当规则被触发时生成 SignalEvent +# +# The abstract base class (ABC) enforces a consistent interface so that +# we can swap strategies without changing any other component. +# 抽象基类强制统一接口,这样我们可以替换策略而不改变其他任何组件。 +# ============================================================================= + +class Strategy(ABC): + """ + Abstract base class for all strategies. + 所有策略的抽象基类。 + """ + + def __init__(self, data_handler: DataHandler, event_queue: queue.Queue): + self.data = data_handler + self.queue = event_queue + + @abstractmethod + def on_market(self, event: MarketEvent) -> None: + """ + Called when a new MarketEvent arrives. + 新的 MarketEvent 到达时调用。 + Subclasses implement their signal logic here. + 子类在这里实现信号逻辑。 + """ + ... + + def send_signal(self, symbol: str, date: pd.Timestamp, + direction: Direction, strength: float = 1.0) -> None: + """Helper to build and queue a SignalEvent. 构建并排队 SignalEvent 的辅助方法。""" + sig = SignalEvent( + symbol = symbol, + date = date, + direction = direction, + strength = strength, + strategy = self.__class__.__name__, + ) + self.queue.put(sig) + + +class MACrossoverStrategy(Strategy): + """ + Dual Moving Average Crossover Strategy 双均线金叉/死叉策略 + ───────────────────────────────────────────────────────────── + Rules / 规则: + Golden Cross (金叉): short SMA crosses ABOVE long SMA → BUY (做多) + Death Cross (死叉): short SMA crosses BELOW long SMA → SELL (平仓) + + Parameters / 参数: + short_window 短期均线窗口 (default 20 days) + long_window 长期均线窗口 (default 60 days) + """ + + def __init__(self, data_handler: DataHandler, event_queue: queue.Queue, + short_window: int = 20, long_window: int = 60): + super().__init__(data_handler, event_queue) + self.short_window = short_window + self.long_window = long_window + self._in_position = False # are we currently holding a long position? + # 当前是否持有多仓? + + def on_market(self, event: MarketEvent) -> None: + """ + Called on every new bar. 每根新K线调用一次。 + + We need at least long_window bars before we can compute the long SMA. + 至少需要 long_window 根K线才能计算长期均线。 + """ + closes = self.data.get_close_series() + n = len(closes) + + # Not enough data yet — wait / 数据不足,等待 + if n < self.long_window: + return + + # Compute SMAs using only data available up to this bar + # 仅使用截至本K线的数据计算均线(无前视偏差) + sma_short = closes.iloc[-self.short_window:].mean() + sma_long = closes.iloc[-self.long_window:].mean() + + # Previous bar's SMAs (to detect crossover / 检测交叉) + if n < self.long_window + 1: + return # need one extra bar to detect a CHANGE in relationship + + 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 # previous relationship + is_above = sma_short > sma_long # current relationship + + # ── Golden Cross 金叉 ───────────────────────────────────────────── + # Short MA just crossed ABOVE long MA → bullish signal + # 短期均线刚刚上穿长期均线 → 看涨信号 + if is_above and not was_above and not self._in_position: + self.send_signal(event.symbol, event.date, Direction.LONG, strength=1.0) + self._in_position = True + + # ── Death Cross 死叉 ───────────────────────────────────────────── + # Short MA just crossed BELOW long MA → exit signal + # 短期均线刚刚下穿长期均线 → 平仓信号 + elif not is_above and was_above and self._in_position: + self.send_signal(event.symbol, event.date, Direction.EXIT, strength=1.0) + self._in_position = False + + +class RSIMeanReversionStrategy(Strategy): + """ + RSI Mean Reversion Strategy RSI 均值回归策略 + ───────────────────────────────────────────── + Rules / 规则: + RSI < oversold (超卖) → BUY (做多) + RSI > overbought(超买) → SELL EXIT (平多) + (Also supports going SHORT / 也支持做空) + + Parameters / 参数: + window RSI计算窗口 (default 14) + oversold 超卖阈值 (default 30) + overbought 超买阈值 (default 70) + """ + + def __init__(self, data_handler: DataHandler, event_queue: queue.Queue, + window: int = 14, oversold: float = 30, overbought: float = 70): + super().__init__(data_handler, event_queue) + self.window = window + self.oversold = oversold + self.overbought = overbought + self._position = 0 # 0=flat, 1=long, -1=short (0=空仓, 1=多仓, -1=空仓) + + def _compute_rsi(self, closes: pd.Series) -> float: + """ + Compute the current RSI value using Wilder's smoothing. + 使用 Wilder 平滑法计算当前 RSI 值。 + """ + if len(closes) < self.window + 1: + return 50.0 # neutral / 中性,数据不足时返回中性值 + + delta = closes.diff() + gain = delta.clip(lower=0) + loss = -delta.clip(upper=0) + avg_gain = gain.ewm(alpha=1.0 / self.window, adjust=False).mean().iloc[-1] + avg_loss = loss.ewm(alpha=1.0 / self.window, adjust=False).mean().iloc[-1] + + if avg_loss == 0: + return 100.0 + rs = avg_gain / avg_loss + return 100.0 - (100.0 / (1.0 + rs)) + + def on_market(self, event: MarketEvent) -> None: + closes = self.data.get_close_series() + rsi = self._compute_rsi(closes) + + # ── Entry: Oversold → Go Long 入场:超卖 → 做多 ────────────────── + if rsi < self.oversold and self._position == 0: + self.send_signal(event.symbol, event.date, Direction.LONG, strength=0.8) + self._position = 1 + + # ── Entry: Overbought → Go Short 入场:超买 → 做空 ─────────────── + elif rsi > self.overbought and self._position == 0: + self.send_signal(event.symbol, event.date, Direction.SHORT, strength=0.8) + self._position = -1 + + # ── Exit Long when RSI recovers to neutral 多仓RSI回到中性时平仓 ── + elif self._position == 1 and rsi > 50: + self.send_signal(event.symbol, event.date, Direction.EXIT, strength=1.0) + self._position = 0 + + # ── Exit Short when RSI falls back to neutral 空仓RSI回到中性时平仓 + elif self._position == -1 and rsi < 50: + self.send_signal(event.symbol, event.date, Direction.EXIT, strength=1.0) + self._position = 0 + + +# ============================================================================= +# SECTION 4: Portfolio 组合管理器 +# ============================================================================= +# +# The Portfolio is the "brain" that manages money. It: +# 1. Receives SignalEvents from the Strategy +# 2. Decides HOW MUCH to trade (position sizing / 仓位管理) +# 3. Creates OrderEvents for the ExecutionHandler +# 4. Receives FillEvents from the broker and updates: +# - Cash balance (现金余额) +# - Positions (持仓) +# - Realized P&L (已实现盈亏) +# - Unrealized P&L(未实现盈亏) +# 5. Records the daily equity (净值) for performance analysis +# +# 组合管理器是管理资金的"大脑"。它接收策略信号,决定交易多少, +# 向执行器发出订单,接收成交回报并更新现金/持仓/盈亏,记录每日净值。 +# +# Position Sizing (仓位管理) methods / 常见方法: +# Fixed Fraction 固定比例 — always use X% of capital (简单,本文采用) +# Fixed Dollar 固定金额 — always trade $N worth +# Kelly Criterion 凯利公式 — optimal fraction based on edge & odds +# Volatility Scaling 波动率缩放 — scale size to target a fixed daily risk +# ============================================================================= + +class Portfolio: + """ + Tracks cash, positions, and P&L. Records equity curve. + 追踪现金、持仓和盈亏,记录净值曲线。 + """ + + def __init__( + self, + data_handler : DataHandler, + event_queue : queue.Queue, + initial_capital: float = 1_000_000.0, + position_pct : float = 0.95, # fraction of capital to deploy per trade + # 每次交易使用资本的比例 (95%) + ): + self.data = data_handler + self.queue = event_queue + self.initial_capital = initial_capital + self.position_pct = position_pct + + # ── Account state 账户状态 ────────────────────────────────────── + self.cash = initial_capital # 现金余额 / available cash + self.positions : Dict[str, int] = {} # symbol → qty (持仓数量) + self.avg_cost : Dict[str, float] = {} # symbol → avg entry price (持仓均价) + + # ── Trade log 交易记录 ─────────────────────────────────────────── + # Every completed fill gets recorded here for analysis. + # 每笔完成的成交都记录在这里以供分析。 + self.trade_log: List[dict] = [] + + # ── Equity curve 净值曲线 ──────────────────────────────────────── + # Recorded once per day after market close. + # 每天收盘后记录一次。 + self.equity_curve: List[dict] = [] + + # ── Computed properties 计算属性 ───────────────────────────────────────── + + def market_value(self, current_prices: Dict[str, float]) -> float: + """ + Current market value of all open positions. + 所有开放持仓的当前市值。 + 市值 = Σ(持仓数量 × 当前价格) + """ + return sum( + qty * current_prices.get(sym, 0.0) + for sym, qty in self.positions.items() + ) + + def total_equity(self, current_prices: Dict[str, float]) -> float: + """ + Total portfolio value: cash + market value of positions. + 账户总价值:现金 + 持仓市值。 + 总资产 = 现金余额 + 持仓市值 + """ + return self.cash + self.market_value(current_prices) + + # ── Event handlers 事件处理器 ───────────────────────────────────────────── + + def on_signal(self, event: SignalEvent) -> None: + """ + Convert a SignalEvent into an OrderEvent. + 将 SignalEvent 转换为 OrderEvent(决定下多少单)。 + + This is where position sizing (仓位管理) happens. + 这里进行仓位计算。 + """ + symbol = event.symbol + direction = event.direction + + # ── Determine quantity 计算交易数量 ───────────────────────────── + # We use Fixed Fraction sizing: deploy position_pct of current equity. + # 使用固定比例法:每次使用当前总资产的 position_pct。 + # + # quantity = floor( equity × position_pct × strength / current_price ) + # 数量 = floor( 总资产 × 仓位比例 × 信号强度 / 当前价格 ) + + current_price = self.data.get_close_series().iloc[-1] + + if direction == Direction.EXIT: + # Close the entire existing position / 平掉全部现有持仓 + qty = abs(self.positions.get(symbol, 0)) + if qty == 0: + return # nothing to close / 没有持仓可平 + else: + # New entry — compute share count / 新入场——计算股数 + capital_to_deploy = self.cash * self.position_pct * event.strength + qty = int(capital_to_deploy / current_price) + if qty == 0: + return # not enough cash / 资金不足 + + order = OrderEvent( + symbol = symbol, + date = event.date, + order_type = OrderType.MARKET, # use market orders for simplicity + direction = direction, + quantity = qty, + ) + self.queue.put(order) + + def on_fill(self, event: FillEvent) -> None: + """ + Update cash and positions when a fill is confirmed. + 成交确认后更新现金和持仓。 + """ + symbol = event.symbol + qty = event.quantity + price = event.fill_price + comm = event.commission + + if event.direction in (Direction.LONG, Direction.SHORT): + # ── Opening a position 建仓 ────────────────────────────────── + sign = 1 if event.direction == Direction.LONG else -1 + + # Update average cost 更新持仓均价 + old_qty = self.positions.get(symbol, 0) + old_cost = self.avg_cost.get(symbol, 0.0) + new_qty = old_qty + sign * qty + if new_qty != 0: + # Weighted average cost 加权平均成本 + self.avg_cost[symbol] = ( + (old_cost * abs(old_qty) + price * qty) / abs(new_qty) + ) + self.positions[symbol] = new_qty + + # Deduct cash (buy) / add cash (short sell) + # 扣除现金(买入)/ 增加现金(卖空) + self.cash -= sign * (price * qty) + comm + + elif event.direction == Direction.EXIT: + # ── Closing a position 平仓 ────────────────────────────────── + entry_price = self.avg_cost.get(symbol, price) + pos_sign = 1 if self.positions.get(symbol, 0) > 0 else -1 + + # Realized P&L 已实现盈亏 + # P&L = (exit_price - entry_price) × qty × direction + pnl = pos_sign * (price - entry_price) * qty - comm + + # Return cash from closing the position + # 平仓回收现金 + self.cash += pos_sign * price * qty - comm + + # Clear position 清除持仓 + self.positions[symbol] = 0 + self.avg_cost[symbol] = 0.0 + + # Log the trade 记录交易 + self.trade_log.append({ + "date" : event.date, + "symbol" : symbol, + "direction" : "LONG→EXIT" if pos_sign == 1 else "SHORT→EXIT", + "entry_price" : entry_price, + "exit_price" : price, + "quantity" : qty, + "pnl" : pnl, + "commission" : comm, + }) + + def record_equity(self, date: pd.Timestamp, current_price: float) -> None: + """ + Snapshot the portfolio value at end of day. + 记录每日收盘时的组合价值快照。 + """ + prices = {self.data.symbol: current_price} + self.equity_curve.append({ + "date" : date, + "cash" : self.cash, + "market_value" : self.market_value(prices), + "total_equity" : self.total_equity(prices), + }) + + def get_equity_series(self) -> pd.Series: + """Return equity curve as a pd.Series. 返回净值曲线为 pd.Series。""" + if not self.equity_curve: + return pd.Series(dtype=float) + df = pd.DataFrame(self.equity_curve).set_index("date") + return df["total_equity"] + + +# ============================================================================= +# SECTION 5: ExecutionHandler (Simulated Broker) +# 执行处理器(模拟券商) +# ============================================================================= +# +# In live trading, this connects to a real broker API (e.g. Interactive +# Brokers, Alpaca, or a Chinese broker via vnpy). +# 在实盘交易中,这里连接真实券商 API(如 Interactive Brokers、Alpaca +# 或通过 vnpy 连接国内券商)。 +# +# In backtesting, we SIMULATE the broker. The key costs to model are: +# 在回测中,我们模拟券商。需要建模的主要成本: +# +# 1. Commission 佣金 +# A fixed percentage of trade value charged by the broker. +# 券商按交易金额收取的固定比例费用。 +# Typical in China A-shares: 万分之三 (0.03%) per side +# 中国A股典型佣金:单边万分之三 +# +# 2. Slippage 滑点 +# The difference between the price at signal time and actual fill price. +# 信号生成时的价格与实际成交价格之间的差异。 +# Causes: bid-ask spread, market impact, order queue position. +# 原因:买卖价差、市场冲击、排队等待。 +# +# Slippage models / 滑点模型: +# Fixed percentage 固定比例 — simple, conservative +# Volume-weighted 成交量加权 — larger orders → more slippage +# Fixed spread 固定点差 — realistic for liquid markets +# +# 3. Fill Assumption 成交假设 +# We assume market orders fill at next bar's OPEN price. +# 我们假设市价单在下一根K线的开盘价成交。 +# This is a conservative and common assumption. +# 这是保守且常用的假设。 +# ============================================================================= + +class SimulatedBroker: + """ + Simulates a brokerage: fills market orders with realistic costs. + 模拟券商:以真实成本成交市价单。 + """ + + def __init__( + self, + event_queue : queue.Queue, + commission_rate : float = 0.0003, # 0.03% per side / 单边万分之三 + slippage_rate : float = 0.0001, # 0.01% slippage / 万分之一滑点 + fill_on : str = "next_open", # when to fill / 何时成交 + min_commission : float = 5.0, # minimum commission / 最低佣金 + ): + """ + Parameters / 参数: + commission_rate 佣金率 (per side / 单边) + slippage_rate 滑点率 (applied as adverse price move / 不利价格移动) + fill_on 成交时机 'next_open' or 'current_close' + min_commission 最低佣金 + """ + self.queue = event_queue + self.commission_rate = commission_rate + self.slippage_rate = slippage_rate + self.fill_on = fill_on + self.min_commission = min_commission + + # Store pending orders to fill on next bar's open + # 存储待处理订单,在下一根K线开盘价成交 + self._pending_orders: List[OrderEvent] = [] + + def on_order(self, event: OrderEvent) -> None: + """ + Receive an order from Portfolio. + 接收来自组合管理器的订单。 + + For market orders, we queue them to fill at next open. + 对于市价单,我们将其排队,在下一根K线的开盘价成交。 + """ + if event.order_type == OrderType.MARKET: + self._pending_orders.append(event) + # Limit and Stop orders would need price monitoring — not implemented here + # 限价单和止损单需要价格监控——此处未实现,留作扩展 + + def fill_pending(self, bar: MarketEvent) -> None: + """ + Fill all pending orders at the open of the provided bar. + 以提供K线的开盘价成交所有待处理订单。 + + This is called at the start of each new bar, before the Strategy + gets to process it. + 每根新K线开始时(策略处理之前)调用。 + """ + for order in self._pending_orders: + fill_price = self._compute_fill_price(order, bar) + commission = self._compute_commission(order, fill_price) + slippage = fill_price - bar.open # adverse difference from open + + fill = FillEvent( + symbol = order.symbol, + date = bar.date, # filled on THIS bar's date + direction = order.direction, + quantity = order.quantity, + fill_price = fill_price, + commission = commission, + slippage = slippage, + ) + self.queue.put(fill) + + self._pending_orders.clear() + + def _compute_fill_price(self, order: OrderEvent, bar: MarketEvent) -> float: + """ + Apply slippage to the open price. + 对开盘价施加滑点。 + + Slippage always works AGAINST the trader: + 滑点总是对交易者不利: + BUY (LONG) : fill above open (买入时成交价高于开盘价) + SELL (EXIT) : fill below open (卖出时成交价低于开盘价) + """ + direction = order.direction + if direction in (Direction.LONG, Direction.SHORT): + # Buying: slippage pushes price UP 买入:滑点使价格上升 + return bar.open * (1 + self.slippage_rate) + else: + # Selling: slippage pushes price DOWN 卖出:滑点使价格下降 + return bar.open * (1 - self.slippage_rate) + + def _compute_commission(self, order: OrderEvent, fill_price: float) -> float: + """ + Compute brokerage commission. + 计算券商佣金。 + + commission = max(commission_rate × trade_value, min_commission) + 佣金 = max(佣金率 × 交易金额, 最低佣金) + """ + trade_value = fill_price * order.quantity + commission = max(trade_value * self.commission_rate, self.min_commission) + return commission + + +# ============================================================================= +# SECTION 6: BacktestEngine 回测引擎(主事件循环) +# ============================================================================= +# +# The BacktestEngine is the conductor — it orchestrates all components by +# running the main event loop. +# 回测引擎是指挥者——通过运行主事件循环来协调所有组件。 +# +# Main Loop Algorithm 主循环算法: +# ────────────────────────────────────────────────────────────────────────── +# WHILE data feed has more bars: +# 1. DataHandler.stream_next() → puts MarketEvent on queue +# 2. Broker.fill_pending(bar) → fills any leftover orders FIRST +# (orders placed yesterday fill at today's open — no lookahead!) +# (昨天的订单今天开盘价成交 — 无前视偏差!) +# 3. WHILE queue is not empty: +# event = queue.get() +# IF MarketEvent → Strategy.on_market(event) +# ELIF SignalEvent → Portfolio.on_signal(event) +# ELIF OrderEvent → Broker.on_order(event) +# ELIF FillEvent → Portfolio.on_fill(event) +# 4. Portfolio.record_equity(today's close) +# END WHILE +# ────────────────────────────────────────────────────────────────────────── +# +# Why is step 2 (fill_pending) before step 3 (process queue)? +# 为什么步骤2(成交待处理)在步骤3(处理队列)之前? +# +# Orders generated on day T are pending. On day T+1 the broker fills them +# at the open BEFORE the strategy sees day T+1's data. +# T日生成的订单是待处理状态。T+1日的开盘价成交(在策略看到T+1数据之前)。 +# This correctly models the one-day execution delay of real trading. +# 这正确模拟了真实交易的一天执行延迟。 +# ============================================================================= + +class BacktestEngine: + """ + Main event-driven backtest engine. 主事件驱动回测引擎。 + Wires together DataHandler, Strategy, Portfolio, and Broker. + 将 DataHandler、Strategy、Portfolio 和 Broker 组合在一起。 + """ + + def __init__( + self, + data_handler : DataHandler, + strategy : Strategy, + portfolio : Portfolio, + broker : SimulatedBroker, + name : str = "Event-Driven Backtest", + ): + self.data = data_handler + self.strategy = strategy + self.portfolio = portfolio + self.broker = broker + self.name = name + + # All components share this single event queue / 所有组件共享这一事件队列 + assert (data_handler.event_queue is strategy.queue is + portfolio.queue is broker.queue), \ + "All components must share the same event queue!" + + self._queue = data_handler.event_queue + + def run(self, verbose: bool = False) -> None: + """ + Execute the backtest from start to finish. + 从头到尾执行回测。 + + Parameters / 参数: + verbose — if True, print every event (useful for debugging) + 如果为 True,打印每个事件(用于调试) + """ + bar_count = 0 + event_count= 0 + + print(f"\n{'─' * 60}") + print(f" 回测开始: {self.name}") + print(f" Backtest Start: {self.data.data.index[0].date()} → " + f"{self.data.data.index[-1].date()}") + print(f"{'─' * 60}") + + # ── Main loop 主循环 ───────────────────────────────────────────── + while self.data.has_more_data(): + + # ── Step 1: Stream the next bar 流出下一根K线 ───────────────── + # This puts a MarketEvent on the queue. + # 这会将一个 MarketEvent 放入队列。 + self.data.stream_next() + bar_count += 1 + + # ── Step 2: Fill yesterday's pending orders at today's open ─── + # ── 步骤2:以今天的开盘价成交昨天的待处理订单 ───────────────── + current_bar = self.data._history[-1] + self.broker.fill_pending(current_bar) + + # ── Step 3: Process all events in the queue 处理队列中的所有事件 + while not self._queue.empty(): + event = self._queue.get() + event_count += 1 + + if verbose: + print(f" [{event.type.value}] {event}") + + if event.type == EventType.MARKET: + # Strategy sees the new price bar and may emit a signal + # 策略看到新K线,可能发出信号 + self.strategy.on_market(event) + + elif event.type == EventType.SIGNAL: + # Portfolio receives signal and may emit an order + # 组合管理器接收信号,可能发出订单 + self.portfolio.on_signal(event) + + elif event.type == EventType.ORDER: + # Broker receives the order (will fill on next bar's open) + # 券商接收订单(将在下一根K线的开盘价成交) + self.broker.on_order(event) + + elif event.type == EventType.FILL: + # Portfolio updates its cash & position records + # 组合管理器更新现金和持仓记录 + self.portfolio.on_fill(event) + + # ── Step 4: Record end-of-day equity 记录当日收盘净值 ─────── + self.portfolio.record_equity(current_bar.date, current_bar.close) + + n_trades = len(self.portfolio.trade_log) + print(f" 回测完成! Backtest Complete!") + print(f" 总K线数: {bar_count} 总事件数: {event_count} 总交易数: {n_trades}") + print(f"{'─' * 60}") + + # ── Performance metrics 绩效指标 ───────────────────────────────────────── + + def metrics(self) -> dict: + """ + Compute performance metrics from the equity curve. + 从净值曲线计算绩效指标。 + """ + eq = self.portfolio.get_equity_series() + ret = eq.pct_change().fillna(0) + n = len(ret) + years = n / 252.0 + + total_return = (eq.iloc[-1] / self.portfolio.initial_capital) - 1 + cagr = (1 + total_return) ** (1 / years) - 1 if years > 0 else 0 + ann_vol = ret.std() * np.sqrt(252) + sharpe = (ret.mean() / ret.std()) * np.sqrt(252) if ret.std() > 0 else 0 + + downside_std = ret[ret < 0].std() * np.sqrt(252) + sortino = cagr / downside_std if downside_std > 0 else 0 + + rolling_max = eq.cummax() + drawdown = (eq - rolling_max) / rolling_max + max_dd = drawdown.min() + calmar = cagr / abs(max_dd) if max_dd != 0 else 0 + win_rate = (ret > 0).mean() + + # Trade-level statistics 交易级别统计 + trades = self.portfolio.trade_log + n_trades = len(trades) + if n_trades > 0: + pnls = [t["pnl"] for t in trades] + win_trades = [p for p in pnls if p > 0] + loss_trades= [p for p in pnls if p < 0] + trade_win_rate = len(win_trades) / n_trades + avg_win = np.mean(win_trades) if win_trades else 0 + avg_loss = np.mean(loss_trades) if loss_trades else 0 + profit_factor = (sum(win_trades) / abs(sum(loss_trades)) + if loss_trades else np.inf) + else: + trade_win_rate = profit_factor = avg_win = avg_loss = 0 + + 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}", + "日胜率 Daily Win Rate" : f"{win_rate:.2%}", + "交易次数 # Trades (round trip)": str(n_trades), + "交易胜率 Trade Win Rate" : f"{trade_win_rate:.2%}", + "平均盈利 Avg Win (per trade)" : f"¥{avg_win:,.0f}", + "平均亏损 Avg Loss (per trade)": f"¥{avg_loss:,.0f}", + "盈亏比 Profit Factor" : f"{profit_factor:.3f}", + } + + def print_metrics(self): + """Pretty-print the performance report. 格式化打印绩效报告。""" + print(f"\n{'=' * 60}") + print(f" 策略绩效报告 / Performance Report") + print(f" {self.name}") + print(f"{'=' * 60}") + for k, v in self.metrics().items(): + print(f" {k:<40} {v}") + + print(f"\n ── 最近 5 笔交易 Last 5 Trades ────────────────────────") + trades = self.portfolio.trade_log[-5:] + if trades: + for t in trades: + pnl_sign = "+" if t["pnl"] >= 0 else "" + print(f" {t['date'].date()} {t['direction']:<12} " + f"进价 {t['entry_price']:>8.2f} 出价 {t['exit_price']:>8.2f} " + f"股数 {t['quantity']:>5} " + f"盈亏 {pnl_sign}{t['pnl']:>10,.0f}¥") + else: + print(" (无完整交易 / No completed round-trip trades)") + print(f"{'=' * 60}") + + +# ============================================================================= +# SECTION 7: Assemble & Run 组装并运行 +# ============================================================================= + +# ── Generate synthetic price data (same seed as previous demo for consistency) +# ── 生成合成价格数据(与前一个演示相同的随机种子以保持一致性) + +def generate_price_series(n_days=1500, mu=0.10, sigma=0.25, s0=100.0, seed=42): + """Geometric Brownian Motion price series. 几何布朗运动价格序列。""" + np.random.seed(seed) + dt = 1.0 / 252 + eps = np.random.randn(n_days) + log_ret = (mu - 0.5 * sigma**2) * dt + sigma * np.sqrt(dt) * eps + prices = s0 * np.exp(np.cumsum(log_ret)) + dates = pd.bdate_range(start="2019-01-02", periods=n_days) + + # Build a realistic OHLCV DataFrame 构建真实的 OHLCV DataFrame + daily_range = prices * np.abs(np.random.randn(n_days)) * 0.015 + opens = prices * (1 + np.random.randn(n_days) * 0.003) + highs = np.maximum(prices, opens) + np.abs(np.random.randn(n_days)) * daily_range * 0.5 + lows = np.minimum(prices, opens) - np.abs(np.random.randn(n_days)) * daily_range * 0.5 + vols = np.random.lognormal(mean=14.5, sigma=0.5, size=n_days).astype(int) + + return pd.DataFrame({ + "open": opens, "high": highs, "low": lows, + "close": prices, "volume": vols, + }, index=dates) + + +ohlcv = generate_price_series() +SYMBOL = "SIM_STOCK" + +print(f"\n[数据] 生成模拟 OHLCV 数据:") +print(f" 交易日数: {len(ohlcv)}") +print(f" 日期范围: {ohlcv.index[0].date()} → {ohlcv.index[-1].date()}") +print(f" 价格区间: {ohlcv['close'].min():.2f} ~ {ohlcv['close'].max():.2f}") + + +def build_engine(strategy_class, strategy_kwargs: dict, + name: str, commission=0.0003, slippage=0.0001 + ) -> BacktestEngine: + """ + Factory function: creates a fresh, independent backtest engine. + 工厂函数:创建一个全新的、独立的回测引擎。 + 每次回测共享同样的价格数据,但所有组件(事件队列、组合、策略) + 都是独立实例,相互不干扰。 + """ + q = queue.Queue() # fresh queue / 全新事件队列 + data = DataHandler(ohlcv, SYMBOL, q) # streams OHLCV bar by bar + strategy = strategy_class(data, q, **strategy_kwargs) + portfolio = Portfolio(data, q, initial_capital=1_000_000.0) + broker = SimulatedBroker(q, commission_rate=commission, + slippage_rate=slippage) + return BacktestEngine(data, strategy, portfolio, broker, name=name) + + +# ── Run MA Crossover 运行双均线策略 ────────────────────────────────────────── +engine_ma = build_engine( + MACrossoverStrategy, + {"short_window": 20, "long_window": 60}, + name="事件驱动 — 双均线策略 (MA Crossover 20/60)", +) +engine_ma.run(verbose=False) +engine_ma.print_metrics() + +# ── Run RSI Mean Reversion 运行RSI均值回归策略 ────────────────────────────── +engine_rsi = build_engine( + RSIMeanReversionStrategy, + {"window": 14, "oversold": 30, "overbought": 70}, + name="事件驱动 — RSI均值回归 (RSI Mean Reversion 14/30/70)", +) +engine_rsi.run(verbose=False) +engine_rsi.print_metrics() + + +# ============================================================================= +# SECTION 8: Visualization 可视化 +# ============================================================================= + +fig = plt.figure(figsize=(16, 20)) +gs = gridspec.GridSpec(5, 2, figure=fig, hspace=0.5, wspace=0.32) + +price_series = ohlcv["close"] + +# ── Plot 1 (top, full width): Price + MA signals +# ── 图1(顶部,全宽): 价格 + 均线信号 +ax1 = fig.add_subplot(gs[0, :]) +ax1.plot(price_series, color="#1f77b4", linewidth=1, alpha=0.9, label="价格 Close") +sma20 = price_series.rolling(20).mean() +sma60 = price_series.rolling(60).mean() +ax1.plot(sma20, color="orange", linewidth=1.3, label="SMA20 (快线)") +ax1.plot(sma60, color="crimson", linewidth=1.3, label="SMA60 (慢线)") + +# Mark trade entries / exits from the event-driven engine +# 标记事件驱动引擎的交易进出场点 +for trade in engine_ma.portfolio.trade_log: + color = "green" if "LONG" in trade["direction"] else "red" + ax1.axvline(x=trade["date"], color=color, alpha=0.25, linewidth=0.8) + +ax1.set_title("双均线策略 — 价格与信号 (MA Crossover: Price + Signals)", + fontsize=12, fontweight="bold") +ax1.legend(loc="upper left", fontsize=9) +ax1.set_ylabel("价格 Price") +ax1.grid(alpha=0.3) + +# ── Plot 2 (full width): Event flow detail for one trade +# ── 图2(全宽): 一笔交易的事件流细节(放大展示) +ax2 = fig.add_subplot(gs[1, :]) +# Show a 120-day window around the first trade to illustrate the event flow +# 展示第一笔交易前后120天的窗口,说明事件流 +if engine_ma.portfolio.trade_log: + trade0 = engine_ma.portfolio.trade_log[0] + focus_date = trade0["date"] + window = pd.Timedelta(days=90) + mask = (price_series.index >= focus_date - window) & \ + (price_series.index <= focus_date + window) + ax2.plot(price_series[mask], color="#1f77b4", linewidth=1.5) + ax2.plot(sma20[mask], color="orange", linewidth=1.2, linestyle="--", label="SMA20") + ax2.plot(sma60[mask], color="crimson", linewidth=1.2, linestyle="--", label="SMA60") + # Mark the exit point + ax2.axvline(x=focus_date, color="red", linewidth=1.5, + label=f"平仓 Exit @ {focus_date.date()}") + ax2.set_title( + f"放大: 第1笔交易前后 90 天 " + f"(Zoom: 90 days around Trade #1 Exit)\n" + f"进价 Entry ¥{trade0['entry_price']:.2f} " + f"出价 Exit ¥{trade0['exit_price']:.2f} " + f"盈亏 P&L ¥{trade0['pnl']:,.0f}", + fontsize=10, fontweight="bold", + ) + ax2.legend(fontsize=9) + ax2.grid(alpha=0.3) + +# ── Plot 3 (left): Equity curves 净值曲线 +ax3 = fig.add_subplot(gs[2, 0]) +eq_ma = engine_ma.portfolio.get_equity_series() +eq_rsi = engine_rsi.portfolio.get_equity_series() +eq_bh = 1_000_000 * (1 + price_series.pct_change().fillna(0)).cumprod() + +ax3.plot(eq_ma, color="steelblue", linewidth=1.5, label="MA策略") +ax3.plot(eq_rsi, color="purple", linewidth=1.5, label="RSI策略") +ax3.plot(eq_bh, color="gray", linewidth=1.2, linestyle="--", label="Buy & Hold") +ax3.set_title("净值曲线对比 (Equity Curves)", fontsize=11, fontweight="bold") +ax3.set_ylabel("账户价值 Portfolio Value") +ax3.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"¥{x/1e4:.0f}万")) +ax3.legend(fontsize=9) +ax3.grid(alpha=0.3) + +# ── Plot 4 (right): Drawdown 回撤曲线 +ax4 = fig.add_subplot(gs[2, 1]) +def compute_dd(eq): + return (eq - eq.cummax()) / eq.cummax() + +dd_ma = compute_dd(eq_ma) +dd_rsi = compute_dd(eq_rsi) +dd_bh = compute_dd(eq_bh) + +ax4.fill_between(dd_ma.index, dd_ma, 0, alpha=0.5, color="steelblue", label="MA策略") +ax4.fill_between(dd_rsi.index, dd_rsi, 0, alpha=0.4, color="purple", label="RSI策略") +ax4.fill_between(dd_bh.index, dd_bh, 0, alpha=0.3, color="gray", label="Buy & Hold") +ax4.set_title("回撤曲线 (Drawdowns)", fontsize=11, fontweight="bold") +ax4.set_ylabel("回撤 Drawdown") +ax4.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:.0%}")) +ax4.legend(fontsize=9) +ax4.grid(alpha=0.3) + +# ── Plot 5 (left): Trade P&L distribution 交易盈亏分布 +ax5 = fig.add_subplot(gs[3, 0]) +pnls = [t["pnl"] for t in engine_ma.portfolio.trade_log] +if pnls: + colors_bar = ["green" if p > 0 else "red" for p in pnls] + ax5.bar(range(len(pnls)), pnls, color=colors_bar, alpha=0.75, edgecolor="white") + ax5.axhline(0, color="black", linewidth=0.8) + ax5.set_title(f"MA策略 — 每笔交易盈亏 (Per-Trade P&L)\n" + f"共{len(pnls)}笔, 胜率 " + f"{sum(1 for p in pnls if p > 0)/len(pnls):.0%}", + fontsize=10, fontweight="bold") + ax5.set_xlabel("交易编号 Trade #") + ax5.set_ylabel("盈亏 P&L (¥)") + ax5.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"¥{x/1e4:.1f}万")) +ax5.grid(alpha=0.3, axis="y") + +# ── Plot 6 (right): Cash vs Market Value over time +# ── 图6(右): 现金 vs 持仓市值随时间变化 +ax6 = fig.add_subplot(gs[3, 1]) +eq_df = pd.DataFrame(engine_ma.portfolio.equity_curve).set_index("date") +ax6.stackplot(eq_df.index, + eq_df["cash"], eq_df["market_value"], + labels=["现金 Cash", "持仓市值 Market Value"], + colors=["#2ca02c", "#ff7f0e"], alpha=0.7) +ax6.set_title("现金 vs 持仓市值 (Cash vs Position Value)", fontsize=11, fontweight="bold") +ax6.set_ylabel("金额 Amount (¥)") +ax6.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"¥{x/1e4:.0f}万")) +ax6.legend(loc="upper left", fontsize=9) +ax6.grid(alpha=0.3) + +# ── Plot 7 (full width): Rolling metrics — Sharpe & Volatility +# ── 图7(全宽): 滚动指标 — 夏普比率 & 波动率 +ax7a = fig.add_subplot(gs[4, 0]) +ax7b = fig.add_subplot(gs[4, 1]) + +ret_ma = eq_ma.pct_change().fillna(0) +roll_sharpe = ret_ma.rolling(60).mean() / ret_ma.rolling(60).std() * np.sqrt(252) +roll_vol = ret_ma.rolling(60).std() * np.sqrt(252) + +ax7a.plot(roll_sharpe, color="steelblue", linewidth=1) +ax7a.axhline(0, color="black", linewidth=0.8, linestyle="--") +ax7a.axhline(1, color="green", linewidth=0.8, linestyle=":", label="Sharpe=1 (目标线)") +ax7a.fill_between(roll_sharpe.index, roll_sharpe, 0, + where=(roll_sharpe > 0), alpha=0.2, color="green") +ax7a.fill_between(roll_sharpe.index, roll_sharpe, 0, + where=(roll_sharpe < 0), alpha=0.2, color="red") +ax7a.set_title("滚动60日夏普比率 (60-day Rolling Sharpe)", fontsize=11, fontweight="bold") +ax7a.set_ylabel("夏普比率 Sharpe") +ax7a.legend(fontsize=8) +ax7a.grid(alpha=0.3) + +ax7b.plot(roll_vol, color="darkorange", linewidth=1) +ax7b.fill_between(roll_vol.index, roll_vol, alpha=0.2, color="orange") +ax7b.set_title("滚动60日年化波动率 (60-day Rolling Volatility)", fontsize=11, fontweight="bold") +ax7b.set_ylabel("年化波动率 Ann. Volatility") +ax7b.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, _: f"{x:.0%}")) +ax7b.grid(alpha=0.3) + +plt.suptitle( + "量化交易 — 事件驱动回测引擎 Quantitative Trading: Event-Driven Backtest", + fontsize=15, fontweight="bold", y=1.003, +) +plt.savefig("event_driven_backtest.png", dpi=120, bbox_inches="tight") +plt.show() +print("\n[图表] 已保存至 event_driven_backtest.png") + + +# ============================================================================= +# SECTION 9: Compare Event-Driven vs Vectorized +# 对比事件驱动 vs 向量化回测 +# ============================================================================= +# +# Run a quick vectorized version of the SAME MA strategy on the SAME data +# to show how results can differ due to: +# 对相同数据运行相同MA策略的快速向量化版本,展示结果差异的原因: +# +# 1. Fill timing 成交时机 — event-driven uses next-bar open; +# vectorized typically uses same-bar close +# 2. Cost modeling 成本建模 — event-driven has min commission floor; +# vectorized applies flat percentage only +# 3. Integer shares 整数股 — event-driven floors to whole shares; +# vectorized trades fractional shares +# ============================================================================= + +print(f"\n{'=' * 60}") +print(" 对比: 事件驱动 vs 向量化回测") +print(" Comparison: Event-Driven vs Vectorized Backtest") +print(f"{'=' * 60}") +print(" 相同策略: 双均线 MA Crossover (20/60)") +print(" 相同数据: 同一模拟价格序列") +print() + +# Vectorized version (快速向量化版本) +closes = ohlcv["close"] +sma20v = closes.rolling(20).mean() +sma60v = closes.rolling(60).mean() +sig_vec = (sma20v > sma60v).astype(float).shift(1).fillna(0) +ret_vec = closes.pct_change().fillna(0) +strat_ret_gross = sig_vec * ret_vec +# Simple flat cost: 0.03% commission + 0.01% slippage = 0.04% per side +position_change = sig_vec.diff().abs().fillna(0) +cost_vec = position_change * (0.0003 + 0.0001) +strat_ret_net = strat_ret_gross - cost_vec +eq_vec = 1_000_000 * (1 + strat_ret_net).cumprod() + +# Compare +m_ed = engine_ma.metrics() +total_ed = float(m_ed["总收益率 Total Return"].strip("%")) / 100 +sharpe_ed = float(m_ed["夏普比率 Sharpe Ratio"]) +maxdd_ed = float(m_ed["最大回撤 Max Drawdown"].strip("%")) / 100 + +total_vec = (eq_vec.iloc[-1] / 1_000_000) - 1 +ret_v_s = strat_ret_net +sharpe_vec = (ret_v_s.mean() / ret_v_s.std()) * np.sqrt(252) +rolling_mx = eq_vec.cummax() +maxdd_vec = ((eq_vec - rolling_mx) / rolling_mx).min() + +print(f" {'指标 Metric':<30} {'事件驱动 Event-Driven':>22} {'向量化 Vectorized':>20}") +print(f" {'─'*72}") +print(f" {'总收益率 Total Return':<30} {total_ed:>21.2%} {total_vec:>19.2%}") +print(f" {'夏普比率 Sharpe Ratio':<30} {sharpe_ed:>21.3f} {sharpe_vec:>19.3f}") +print(f" {'最大回撤 Max Drawdown':<30} {maxdd_ed:>21.2%} {maxdd_vec:>19.2%}") +print() +print(" 差异原因 / Why they differ:") +print(" ① 事件驱动以「下一日开盘价」成交 (Event-driven fills at next open)") +print(" 向量化以「当日收盘价」交易 (Vectorized trades at same-day close)") +print(" ② 事件驱动有最低佣金下限 ¥5 (Event-driven has min commission floor)") +print(" ③ 事件驱动按整数股买卖 (Event-driven uses integer share quantities)") +print(" ④ 资金是随时间变化的 (Capital base grows/shrinks with P&L)") + + +# ============================================================================= +# SECTION 10: What's Next 下一步 +# ============================================================================= +print(f""" +{'=' * 72} + 总结 Summary +{'=' * 72} + + 事件驱动回测引擎的核心组件 / Core Components of Event-Driven Engine: + + ┌─────────────────────┬────────────────────────────────────────────┐ + │ 组件 Component │ 职责 Responsibility │ + ├─────────────────────┼────────────────────────────────────────────┤ + │ Event (事件) │ 解耦组件间通信的消息对象 │ + │ │ Message objects that decouple components │ + ├─────────────────────┼────────────────────────────────────────────┤ + │ DataHandler │ 逐根K线流出历史数据(模拟无未来信息) │ + │ 数据处理器 │ Streams bars one-by-one (no future info) │ + ├─────────────────────┼────────────────────────────────────────────┤ + │ Strategy (策略) │ 观察市场 → 生成交易信号 │ + │ │ Observes market → generates trade signals │ + ├─────────────────────┼────────────────────────────────────────────┤ + │ Portfolio (组合) │ 仓位管理 + 现金/持仓/盈亏追踪 │ + │ │ Position sizing + cash/position/P&L track │ + ├─────────────────────┼────────────────────────────────────────────┤ + │ SimulatedBroker │ 模拟券商:滑点+佣金+成交时机 │ + │ 模拟券商 │ Simulates broker: slippage+commission+fill │ + ├─────────────────────┼────────────────────────────────────────────┤ + │ BacktestEngine │ 主事件循环,驱动所有组件 │ + │ 回测引擎 │ Main event loop, orchestrates all parts │ + └─────────────────────┴────────────────────────────────────────────┘ + + 下一步学习方向 Next Steps: + ────────────────────────────────────────────────────────────────── + • 限价单/止损单 Limit & Stop orders in ExecutionHandler + • 多标的组合 Multi-asset portfolio (stocks + bonds, sector rotation) + • 因子选股 Alpha factor models (Fama-French, Momentum, Quality) + • 机器学习信号 ML-based signals (XGBoost, LSTM) + • 风险管理 Risk management (VaR, CVaR, Kelly position sizing) + • 实盘对接 Live trading connection (vnpy, Alpaca API) +{'=' * 72} +""") diff --git a/quant_strategy_backtest_demo.py b/quant_strategy_backtest_demo.py new file mode 100644 index 0000000..d4cf9ce --- /dev/null +++ b/quant_strategy_backtest_demo.py @@ -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} +""") diff --git a/return_distribution.png b/return_distribution.png new file mode 100644 index 0000000..06568fe Binary files /dev/null and b/return_distribution.png differ diff --git a/strategy_backtest_demo.png b/strategy_backtest_demo.png new file mode 100644 index 0000000..5abd6a7 Binary files /dev/null and b/strategy_backtest_demo.png differ