用向量化回测跑历史,像看一场按了快进的事后重放:所有行情一次性倒进流水线,几行矩阵运算吐出净值曲线,又快又优雅。但只要你想往前多走一步——比如「浮亏超过 5% 就止损」「只用账户里闲置的现金分批加仓」——就会发现这台快进机器开始力不从心:它根本没有「账户」这个概念。
上一篇我们给第 18 篇那条净值曲线装上了年化、夏普、回撤这些仪表盘;但仪表盘再精确,前提是「发动机」本身诚实。本篇就换一台结构完全不同的发动机:事件驱动回测(Event-Driven Backtest)。它不玩矩阵,而是让时间一天一天往前走,每天像实盘一样做一次决策。我们会用一百行以内的代码写一个极简引擎,跑同一个双均线策略,再和第 18 篇的向量化结果对照,解释数字差异从哪来。
更重要的是,本篇在为第 21 篇铺路:Backtrader 本质上就是一台功能齐全、工程成熟的事件驱动回测框架。理解了这个百行小引擎,你读 Backtrader 的 next() 方法时会有强烈的既视感——原来只是把我们的 run() 循环做得更工程化了而已。
本篇目标
- 能说出向量化回测的三个局限,以及什么样的策略需求必须换事件驱动。
- 能说出事件驱动引擎的四个核心状态:逐 bar 推进、订单、持仓、现金,并在代码里指出它们各自的载体。
- 能读懂并动手修改一个一百行以内的极简事件驱动类,跑通双均线回测。
- 能解释事件驱动与向量化结果差异的三个来源,并判断差异量级是否正常。
核心概念
向量化回测的三个局限
第 18 篇的四步流水线(信号 → 持仓 → 日收益 → 净值)优点是快、代码短、天然防某些未来函数,但它有三个结构性局限:
- 状态表达不了。向量化里只有「仓位比例」这一个隐式状态,没有独立的现金账户和持股数量。「只有 5 万现金、股价 12 元时最多买 4100 股」这类真实约束写不出来,「先买一半、跌了再加」的部分加减仓也别扭。
- 盘中触发难写。止损是「盘中价格触及阈值就卖」,而向量化手里只有收盘后的一根 K 线,硬要用
shift和布尔掩码拼出「盘中触发」,代码很快变成迷宫,还容易拼出未来函数。 - 策略与机器搅在一起。流水线是专为「双均线 + 全仓」定制的,换个策略就要重写一遍。而理想的回测机器应该像考场:策略只是「每收到一根 K 线,交出一张订单」的答题者,机器负责撮合记账——换策略只换答题者。
只要你的需求碰到这三条中的任何一条,就该换事件驱动了。
事件驱动思想:把回测当成逐日重演的实盘
事件驱动的世界观是:时间不是一整块矩阵,而是一根一根到来的 K 线(bar)。每根 bar 到来时,引擎按固定顺序走一遍循环:
- 决策:用截至今天(教学假设含当天收盘价)及更早的数据计算信号——引擎永远只允许看历史;
- 下单:把信号差异变成一张张订单(Order):买多少股、卖多少股;
- 撮合:按约定的成交价(本篇教学假设当日收盘价成交)扣钱、扣手续费、更新持仓;
- 记账:现金加持仓市值等于当日组合权益,记入净值曲线。
这个循环和实盘交易的大脑同构:实盘程序每天收到的也是一根根新 K 线,做的也是「决策—下单—记账」。所以事件驱动回测的代码,将来换个数据源就能几乎原样滑向模拟盘与实盘(第 45、46 篇的主线)。
最小引擎的四个状态
一百行以内的极简引擎,只要维护好四个状态就成立,一一对应到代码载体:
| 状态 | 代码载体 | 职责 |
|---|---|---|
| 逐 bar 推进 | run() 里的 for 循环 |
每根 K 线走一遍「决策—撮合—记账」 |
| 订单 | self.orders 列表 |
记录每笔买卖的方向、数量、价格、手续费 |
| 持仓 | self.shares |
当前持有股数,决定权益对价格的敏感度 |
| 现金 | self.cash |
闲置资金,成交与手续费的结算账户 |
划重点:事件驱动的防未来函数纪律比向量化更直观——只需盯住一句话:
signal_of(i)里只允许出现第 i 根及更早的数据,且撮合价不能早于决策所用信息的时点。本篇沿用第 18 篇的教学简化:信号用当日收盘价计算、并假设恰好以当日收盘价成交,两处时点一致才不算偷跑。
和向量化对照:差异从哪来
同一策略、同一数据,两台机器的期末净值应当接近但不完全相等。差异来源有三,每一条都值得写进你的笔记:
- 整数股 vs 比例仓位。引擎按
int(可用现金 ÷ 股价)买整数股,买完总有一点零钱躺在现金账户里不生息;向量化假设仓位恰好 100%,没有零钱问题。 - 手续费的记账方式。引擎从现金里真实扣钱,改变了复利的基数;向量化只是从当日收益率里减一个数,两者在数学上近似但不严格相等。
- 边界与归一化细节。慢线形成前的日子里,向量化靠
NaN与fillna(0)处理,引擎靠「数据不足就按空仓」处理;净值起点也可能差一天,这些都会带来微小差别。
正常的差异量级是万分之几到千分之几。如果你照着本篇实现,两台机器差出了百分之几甚至更多,先别调参——先查未来函数,八成是某处偷看了明天的数据。
完整代码
依赖安装:uv pip install pandas numpy akshare。本篇不需要画图。三个代码块按顺序放在同一个脚本里执行。
代码块 1:加载数据(与第 18、19 篇逐字相同,保证三篇可比):
import numpy as np
import pandas as pd
def load_data():
"""优先 akshare 真实行情,失败则退回固定种子的模拟数据。"""
try:
import akshare as ak
raw = ak.stock_zh_a_hist(symbol="000001", period="daily",
start_date="20200101", end_date="20241231",
adjust="qfq") # 前复权,保证价格连续
raw = raw.rename(columns={"日期": "date", "收盘": "close"})
df = raw[["date", "close"]].copy()
df["date"] = pd.to_datetime(df["date"])
df = df.set_index("date")
print(f"已加载 akshare 真实数据:000001,共 {len(df)} 个交易日")
except Exception as e:
print(f"akshare 不可用({e}),改用本地模拟数据")
rng = np.random.default_rng(7)
idx = pd.bdate_range("2020-01-01", "2024-12-31")
df = pd.DataFrame(
{"close": 10 * np.cumprod(1 + rng.normal(0.0002, 0.015, len(idx)))},
index=idx)
df.index.name = "date"
print(f"已生成模拟数据,共 {len(df)} 个交易日")
return df
df = load_data()
代码块 2:极简事件驱动引擎(类本体五十行左右,含全部四个状态):
import numpy as np
import pandas as pd
FEE = 0.001 # 双边 0.1% 手续费(系列统一口径)
FAST, SLOW = 5, 20 # 双均线参数:快线 5 日,慢线 20 日
class EventBacktester:
"""极简事件驱动回测引擎:逐 bar 推进 + 订单 + 持仓 + 现金。"""
def __init__(self, df, cash=100_000.0):
self.df = df # 行情数据(只读:引擎不许修改它)
self.cash = cash # 状态:现金
self.shares = 0 # 状态:持仓(股数)
self.orders = [] # 状态:订单流水
self.prev_signal = 0 # 昨日信号,用来探测金叉 / 死叉
self.navs = {} # 状态:逐日净值(日期 -> 组合权益)
def signal_of(self, i):
"""只用第 i 根及更早的收盘价计算双均线信号——引擎的「只看历史」纪律。"""
closes = self.df["close"].iloc[max(0, i - SLOW + 1): i + 1]
if len(closes) < SLOW: # 慢线尚未形成:视为空仓信号
return 0
ma_fast = closes.tail(FAST).mean() # 最近 5 日收盘均价
ma_slow = closes.mean() # 最近 20 日收盘均价
return int(ma_fast > ma_slow)
def place_order(self, i, side, price):
"""生成并立即撮合一张全仓市价单(教学简化:以当日收盘价成交)。"""
if side == "buy":
qty = int(self.cash * (1 - FEE) / price) # 留出手续费后买得起的整数股
self.cash -= qty * price * (1 + FEE) # 现金流出:股款 + 手续费
self.shares += qty # 持仓增加
else:
qty = self.shares # 清仓:卖掉全部持仓
self.cash += qty * price * (1 - FEE) # 现金流入:股款 − 手续费
self.shares = 0 # 持仓清零
fee = qty * price * FEE
self.orders.append({"date": self.df.index[i].strftime("%Y-%m-%d"),
"side": side, "shares": qty,
"price": round(price, 2), "fee": round(fee, 2)})
def run(self):
"""主循环:逐 bar 推进——先决策,再撮合,收盘后记账。"""
for i in range(1, len(self.df)):
price = self.df["close"].iloc[i]
sig = self.signal_of(i) # 决策只用到今天及更早的数据
if sig == 1 and self.prev_signal == 0 and self.shares == 0:
self.place_order(i, "buy", price) # 金叉:全仓买入
elif sig == 0 and self.prev_signal == 1 and self.shares > 0:
self.place_order(i, "sell", price) # 死叉:清仓卖出
self.prev_signal = sig
equity = self.cash + self.shares * price # 组合权益 = 现金 + 持仓市值
self.navs[self.df.index[i]] = equity
nav = pd.Series(self.navs).sort_index()
return nav / nav.iloc[0] # 归一化成净值曲线
代码块 3:跑引擎,并与第 18 篇的向量化口径对照:
import numpy as np
import pandas as pd
# —— 跑事件驱动引擎 ——
engine = EventBacktester(df, cash=100_000.0)
nav_event = engine.run()
# —— 第 18 篇的向量化口径,同参数复刻一遍 ——
signal = (df["close"].rolling(FAST).mean() > df["close"].rolling(SLOW).mean()).astype(int)
position = signal.shift(1).fillna(0).astype(int) # 信号次日执行,防未来函数
asset_ret = df["close"].pct_change().fillna(0)
cost = position.diff().abs().fillna(0) * FEE # 双边 0.1%
nav_vector = (1 + position * asset_ret - cost).cumprod()
print(f"事件驱动 期末净值:{nav_event.iloc[-1]:.4f}")
print(f"向量化 期末净值:{nav_vector.iloc[-1]:.4f}")
diff = abs(nav_event.iloc[-1] - nav_vector.iloc[-1]) / nav_vector.iloc[-1]
print(f"相对差异:{diff:.4%}")
print(f"成交笔数(单边):{len(engine.orders)}")
for order in engine.orders[:3]: # 前 3 张订单长这样
print(order)
代码逐段解释
1. 块 2 之状态区(__init__):四个状态各就各位——cash 是结算账户,shares 是持仓股数,orders 攒订单流水,navs 记逐日权益;prev_signal 不算独立状态,它是「昨天的信号」,专门用来探测信号跳变(0→1 金叉、1→0 死叉),和第 18 篇 diff() 找跳变是同一个思想,只是在循环里手工完成。特别注意 self.df 的注释:引擎对行情数据只有读权限,这是纪律,防止有人偷偷改数据「修正」回测结果。
2. 块 2 之决策区(signal_of):切片 iloc[max(0, i - SLOW + 1): i + 1] 是含头含尾的最近 20 根收盘价(下标 i 自身包含在内),tail(FAST) 取其中最后 5 根算快线——与 pandas 的 rolling(5)、rolling(20) 在第 i 根上的取窗完全一致。数据不足 20 根时返回 0,对应向量化里「均线为 NaN 时条件为假、信号为 0」的行为,两台机器在开头 20 天的表现就这样对齐了。
3. 块 2 之撮合与主循环(place_order 与 run):买入时 int(现金 × (1 − 费率) ÷ 股价) 先把手续费预留出来,再买整数股,余款留在现金账户;卖出则全额清仓、现金回流并扣费。run() 的顺序绝不能乱:先决策(此时只用历史数据),再撮合(现金与持仓易主),最后记账(现金 + 持仓市值)。如果把撮合挪到决策前,相当于用还没更新的账户状态下单,会出现「现金为负」这类荒唐账。循环结束后把权益字典转成序列并除以首日,得到和向量化同口径的净值曲线。
4. 块 3(对照):向量化一侧逐行复刻第 18 篇,参数、费率、复权口径全部一致。读输出时记住本篇立下的预期:两台机器的期末净值接近,差异在万分之几量级,来源就是核心概念里那三条——整数股的零钱、现金复利基数、边界处理。
示例输出(示意,与第 18、19 篇示例输出同一演示口径;实际数值随数据变化,但形态如此):
已加载 akshare 真实数据:000001,共 1215 个交易日
事件驱动 期末净值:1.4187
向量化 期末净值:1.4230
相对差异:0.3022%
成交笔数(单边):28
{'date': '2020-02-21', 'side': 'buy', 'shares': 9073, 'price': 11.01, 'fee': 99.87}
{'date': '2020-03-09', 'side': 'sell', 'shares': 9073, 'price': 10.45, 'fee': 94.81}
{'date': '2020-04-14', 'side': 'buy', 'shares': 8952, 'price': 10.57, 'fee': 94.62}
订单流水里能直接验算:首笔买入 9073 股 × 11.01 元 × 0.001 ≈ 99.87 元手续费,与记录吻合;卖出后现金约 9.47 万元,下一笔只能买 8952 股——整数股与现金约束的效果一目了然,这是向量化里完全看不见的细节。
划重点:两台机器差异小是常态、是验证,不是缺陷。看到差异先高兴(两套独立实现互相印证了),再看量级(万分之几正常),最后才谈排查(差异大到百分之几,优先查未来函数)。把「双实现对照」当成你日后写回测的标准质检流程。
常见坑与小练习
坑 1:把整表算好的指标直接传进引擎。 现象是引擎跑得飞快、成绩好得惊人。原因:比如在外面先算好全期的 rolling 均线再切片喂给引擎,一旦切片下标错位,引擎就会用未来的均线值决策。避免方式:让引擎自己在 signal_of 里现场计算,输入只有原始行情。
坑 2:在引擎里修改行情数据。 现象是同一份代码两次运行结果不同,或对照实验对不上。原因:某处为了「方便」改了 self.df,污染了后续所有 bar。避免方式:构造函数里复制一份(df.copy()),或养成「引擎只读」的肌肉记忆。
坑 3:允许现金为负、股数为小数。 现象是净值曲线莫名平滑、收益虚高。原因:撮合时没检查资金充足性,或按小数股成交,等价于允许无限透支。避免方式:买入数量用 int() 向下取整,撮合前检查现金约束,本篇的 place_order 就是范本。
坑 4:决策与成交的时点错位。 现象是把成交价改成「次日开盘」后,净值没有明显变化甚至更差,怀疑写错。原因:成交时点后移,持仓的收益窗口要整体跟着后移一天,若只改成交价不改收益归属,等于白改。避免方式:每改一次成交假设,画一张「决策时刻 → 成交时刻 → 收益窗口」的时间线图核对。
坑 5:强迫两台机器数字完全一致。 现象是为了对齐向量化结果,把引擎改出各种奇怪的特判。原因:整数股与现金复利带来的差异是真实世界的摩擦,强行抹平反而引入失真。避免方式:接受合理量级的差异,把对照当成质检手段而非对答案。
小练习 1:给引擎加一条硬止损——持仓期间组合权益较历史峰值回撤超过 5% 就立即清仓,并统计止损触发次数。
提示:在
run()循环里维护一个历史峰值变量,撮合区加一个判断分支;注意触发当日不许再买回,否则会死循环式进出。
小练习 2:把买入数量改成可用现金的一半(半仓起买),观察期末净值与相对差异怎么变化。
提示:现金闲置比例上升后,引擎与向量化的差异会拉大——正好验证「零钱不生息」这条差异来源。
小练习 3:给行情补一列开盘价(akshare 的「开盘」列),把撮合价改成次日开盘价,重跑并对比期末净值。
提示:这是更真实的成交假设;记得收益窗口整体后移一天,别只改价格——这一步做完,你对「成交假设决定回测结论」会有切肤体会。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。