NautilusTrader量化交易终极指南-第4章第1节-从向量化到事件驱动
一句话导读:同样的策略,用 pandas 整列一次性算出回测收益,和用事件驱动逐条消息处理,看起来只是写法差异,实际上决定了你能不能把回测结果当真。
本文导航
- 为什么先讲这个
- pandas 向量化回测长什么样
- NautilusTrader 事件驱动怎么写
- 两种范式到底差在哪
- 可信度与实盘一致性的本质
- 小结
- 下节预告
为什么先讲这个
我在研发生成式 AI 应用的日志系统时养成的习惯是先搞清楚"数据从哪来到哪去",做量化也一样。你写回测,本质上是在模拟一套系统在真实行情流里如何做决策。这个"如何"是逐个 tick 地响应,还是拿着整张 K 线表算一遍,结果根本是两码事。
NautilusTrader 敢拿 Rust 写核心、用纳秒时间戳、把事件驱动一路贯彻到底,出发点就是要让回测和实盘跑同一套代码。这一节我从最经典的 pandas 向量化回测讲起,再带你切到 NautilusTrader 的事件驱动写法,把"为什么回测结果不可信"这件事讲透。
pandas 向量化回测长什么样
先看最典型的向量化做法。拿日线 K 线,算个均线金叉策略,用 pandas 直接把整列的价格一口气算完。
importpandasaspd# 假设 df 是 ['open','high','low','close','volume'] 的日线df["ma_fast"]=df["close"].rolling(10).mean()df["ma_slow"]=df["close"].rolling(30).mean()# 逐行(其实还是整列向量化)生成持仓信号df["signal"]=(df["ma_fast"]>df["ma_slow"]).astype(int)df["position"]=df["signal"].diff().fillna(0)# 1 开多,-1 平多# 用次日收盘价成交df["returns"]=df["close"].pct_change()df["strategy"]=(df["position"].shift(1)*df["returns"]).fillna(0)df["equity"]=(1+df["strategy"]).cumprod()这段代码在学术界和很多入门教程里是标准写法,跑起来也快——pandas 底层是 numpy,整列加减乘除全是 C 速度。
但它有几个问题,我要拎出来说:
- 信号在收盘后才知道,却用当天收盘价成交。现实中你看到收盘价的那一刻,这个 bar 已经结束,最快也只能用下一根的开盘或收盘成交。虽然上面代码用了
shift(1)假装"下一天",但很多人这里根本不 shift,直接当天成交,等于你能看到未来。 - 没有滑点、没有手续费、没有最小交易单位。
pct_change()假设你随时可以按收盘价买卖任意数量,账户里钱够不够从不管。 - 持仓变化是"事后"累加的。整张表先算完信号,再往回套收益,等于所有决策点在计算开始前就全"看"过了,这不是一个在时间轴上推进的过程。
向量化的本质是离线批量计算——它假设整段历史都躺在你面前,你随时可以回看。这在数据处理里是好事,在回测里恰恰是灾难,因为它让"偷看未来"变得几乎无成本。
NautilusTrader 事件驱动怎么写
NautilusTrader 不这么干。它把回测看成一场"重放":行情数据作为事件流,一条条喂给策略,策略只能基于当前及之前已经看到的消息做决定,看不到后面任何一根 K 线。
写法示意如下(为突出重点我简化了合约和参数配置):
fromnautilus_trader.modelimportBar,BarTypefromnautilus_trader.model.enumsimportOrderSide,TimeInForcefromnautilus_trader.trading.strategyimportStrategyclassMaCross(Strategy):def__init__(self,bar_type,fast=10,slow=30):super().__init__()self.bar_type=bar_type self.fast=fast self.slow=slow self.fast_ma=[]self.slow_ma=[]defon_start(self):# 订阅 1 分钟的 bar 行情self.subscribe_bars(self.bar_type)defon_bar(self,bar:Bar):# 事件驱动:每来一根新 bar 触发一次self.fast_ma.append(bar.close.as_double())self.slow_ma.append(bar.close.as_double())self.fast_ma=self.fast_ma[-self.fast:]self.slow_ma=self.slow_ma[-self.slow:]iflen(self.slow_ma)<self.slow:return# 数据不够,不交易fast_val=sum(self.fast_ma)/len(self.fast_ma)slow_val=sum(self.slow_ma)/len(self.slow_ma)iffast_val>slow_valandnotself.portfolio.is_flat(self.symbol):# 金叉:开多self.submit_order(self.order_factory.market(instrument_id=self.symbol,order_side=OrderSide.BUY,quantity=self.qty,time_in_force=TimeInForce.GTC,))eliffast_val<slow_valandnotself.portfolio.is_flat(self.symbol):# 死叉:平仓走人self.close_all_positions(self.symbol)关键不在算法本身,在于触发机制:on_bar是被消息总线驱动的回调。内核在某个时间点上收到一根 bar,就把这条消息交给策略,策略判断、下单,订单再作为事件回到执行引擎确认成交。整条链路是时间上一个点一个点推进的,策略拿到的永远是"此刻"的世界视图,永远不可能用到未来的数据。
这就是事件驱动和向量化的分水岭。
两种范式到底差在哪
我用一张图把两种范式的时间处理方式对比一下:
左边是"把整个时间轴拍平成一列",右边是"顺着时间轴一格一格走"。本质区别是:向量化假设全部信息同时可得,事件驱动强行建立信息的时间有序性。
再看几个具体层面:
| 维度 | pandas 向量化 | 事件驱动 |
|---|---|---|
| 计算方式 | 整列 numpy 一次性算 | 逐事件回调,顺序执行 |
| 时间模型 | 编排行号,可随机访问 | 单调递增时间戳,只前不退 |
| 未来信息 | 容易不小心用到 | 结构上杜绝 |
| 摩擦成本 | 难加入,需要后处理 | 引擎层面原生处理滑点手续费 |
| 换到实盘 | 无法复用,重写 | 同一套代码直接跑 |
可信度与实盘一致性的本质
这件事我想了很久,最后归结为一句话:向量化回测测的不是你实盘会跑的策略,而是"已知全部历史的最优事后策略"。
实盘里信号来了,你要当场决定买不买,没有整张表可以回头看。而向量化回测偷偷把"未来"递给了策略,结果就是回测收益系统性偏高——我见过不少因子回测年化 30%+,上实盘直接变负的案例,十有八九是信号和成交时间错位或者用了未来数据。
NautilusTrader 之所以坚持事件驱动,是为了达成一个工程目标:train once, run everywhere。回测、模拟、实盘,跑的是同一套策略代码、同一套内核逻辑,只是行情来源和执行通道不同。回测里on_bar收到的 bar 是重放的缓存,实盘里收到的是交易所推送,策略不需要知道区别。
这带来的直接好处是两个:
- 回测可信。因为执行路径和实盘一致,滑点、手续费、部分成交、订单拒绝这些真实摩擦力都在回测里被建模了,收益曲线不再是一片虚高。
- 上线零迁移成本。策略写一次,回测赚了,切 Live 直接把运行环境从
BacktestEngine换成LiveEngine,冒的风险只是适配器的网络延迟,而不是逻辑重写。
这也是我强烈建议你从第一天就用事件驱动框架而不是用写论文的向量化工具做可实盘策略的原因。你可以在 pandas 里做因子探索和快速验证,真到要上策略,必须迁到事件驱动。
小结
- pandas 向量化回测是离线批量计算,结构上允许偷看未来,回测收益可信度低。
- 事件驱动让策略只能基于当前和过去的事件做决策,从架构上杜绝未来函数。
- NautilusTrader 用同一套代码跑回测、模拟、实盘,保证回测与实盘一致。
- 因子探索可以用 pandas,可交付策略必须走事件驱动。
下节预告
既然事件驱动强调"确定性地推进时间",那时间的精度和价格的精度就极其重要。下一节我讲 NautilusTrader 为确定性而生的领域模型——UnixNanos 纳秒时间戳、Price/Quantity 定点数精度,以及为什么用浮点做结算价格会翻车。
如果这篇对你有帮助,点赞收藏关注三连,量化路上不迷路。