第 3 章 · 02 Backtesting 与 LiveTrading 双实现对照 ★ 本节摘要:这是全书第一个高潮(★)。本节钻透 Lean 最优雅的设计——回测与实盘共用同一套引擎主体。上一节讲了七大 Handler 接口,本节揭示每个接口都有 与 两套实现。切换运行模式,引擎主体( / / )一行代码都不用改,只需改 里 字段,装配器就会把不同的 handler 类名字符串实例化成对应的实现。差异只收敛到三个分叉点:① 的 (时间推进方式)② 的 environment 选哪套 handler ③ 里几处 分支(consolidator 扫描/margin call/delisting)。用户写的 代码对运行模式完全无感。
本节摘要:这是全书第一个高潮(★)。本节钻透 Lean 最优雅的设计——回测与实盘共用同一套引擎主体。上一节讲了七大 Handler 接口,本节揭示每个接口都有
Backtesting*与LiveTrading*两套实现。切换运行模式,引擎主体(Program.cs/Engine.cs/AlgorithmManager.cs)一行代码都不用改,只需改config.json里environment字段,装配器就会把不同的 handler 类名字符串实例化成对应的实现。差异只收敛到三个分叉点:①Engine.cs的_liveMode ? LiveSynchronizer : Synchronizer(时间推进方式)②config.json的 environment 选哪套 handler ③AlgorithmManager里几处_liveMode分支(consolidator 扫描/margin call/delisting)。用户写的QCAlgorithm代码对运行模式完全无感。读完本节,你会理解为什么 Lean 被称为"回测实盘无缝切换的典范"。
内容来源:原项目源码
Engine/Engine.cs、Engine/AlgorithmManager.cs、Launcher/config.json、Engine/DataFeeds/{FileSystemDataFeed,LiveTradingDataFeed,Synchronizer,LiveSynchronizer}.cs、各 Handler 的双实现类,精读并套用体系化模板。
⚠️ 注意:本节是"高潮"节,篇幅充实。核心心法只有一句——引擎依赖接口,不依赖实现。所有模式差异都通过"换接口实现类"消化掉,用户代码与引擎主体都看不到 if(backtest)/if(live) 的 sprawl。请重点抓住"三个分叉点",它们是理解整个设计的钥匙。
阅读完本节,你应当能够:
FileSystemDataFeed(读磁盘)与 LiveTradingDataFeed(实时行情)的本质差异。live-paper 环境的 transaction-handler 仍是 BacktestingTransactionHandler。先给一张全景表,把七个接口的两套实现摆出来:
| 接口 | 回测实现 | 实盘实现 |
|---|---|---|
IDataFeed |
FileSystemDataFeed |
LiveTradingDataFeed |
ISetupHandler |
BacktestingSetupHandler |
BrokerageSetupHandler |
IResultHandler |
BacktestingResultHandler |
LiveTradingResultHandler |
ITransactionHandler |
BacktestingTransactionHandler |
BrokerageTransactionHandler |
IRealTimeHandler |
BacktestingRealTimeHandler |
LiveTradingRealTimeHandler |
IBrokerage |
BacktestingBrokerage |
真实券商(IB/FXCM/...)或 PaperBrokerage |
| (实盘独有) | — | data-queue-handler(实时行情源) |
注意命名规律:回测侧全用 Backtesting* 前缀(除 DataFeed 用 FileSystem、Brokerage 用 BacktestingBrokerage);实盘侧用 LiveTrading* 或 Brokerage* 前缀。光看类名就能判断它服务哪种模式。
Engine/DataFeeds/FileSystemDataFeed.cs:39-40:
39 /// <remarks>Filesystem datafeeds are incredibly fast</remarks> 40 public class FileSystemDataFeed : IDataFeed
源码注释直白——"文件系统数据源极快"。它的 CreateSubscription 直接从磁盘 Data/ 目录读取因子化、复权后的 CSV/zip 文件,没有网络开销,没有等待。回测能跑出"每秒百万数据点"的吞吐(L404 的 kps 统计),靠的就是纯本地 IO。
Engine/DataFeeds/LiveTradingDataFeed.cs(类声明注释):
/// <summary> /// Provides an implementation of <see cref="IDataFeed"/> that is designed to deal with /// live, remote data sources /// </summary> public class LiveTradingDataFeed : IDataFeed
实盘数据源面向"远程实时数据"。它的数据不来自磁盘,而来自 data-queue-handler(实盘独有组件)。config.json 里每个实盘环境都带这个字段:
// Launcher/config.json:388(live-paper 环境) "data-queue-handler": [ "QuantConnect.Lean.Engine.DataFeeds.Queues.LiveDataQueue" ],
// Launcher/config.json:399(live-zerodha 环境,真实券商) "data-queue-handler": [ "ZerodhaBrokerage" ],
💡 钻取要点(高潮心法):注意
live-zerodha的data-queue-handler直接是券商类名ZerodhaBrokerage——一个类同时实现IBrokerage(下单)和IDataQueueHandler(行情)。这是实盘的典型设计:券商既报行情又接订单,一个 WebSocket 通道复用。回测没有这个字段,因为数据全在磁盘,不需要"队列订阅"。这就是"实盘独有组件"的来历——回测的FileSystemDataFeed直接读文件,实盘的LiveTradingDataFeed要先通过 data-queue-handler 订阅实时流,再喂给算法。
用户下单 → 它直接调 BacktestingBrokerage.PlaceOrder → BacktestingBrokerage 内部用 IFillModel(如 ImmediateFillModel)算出成交价 → 触发 OrdersStatusChanged 事件 → TransactionHandler 把成交回填给 Portfolio。整个过程在内存里瞬间完成,没有滑点(除非用户配置了 ISlippageModel)。
用户下单 → 它把订单转发给真实券商(IBrokerage.PlaceOrder)→ 券商通过 API 提交到交易所 → 交易所部分成交/全部成交 → 券商回调 OrdersStatusChanged → TransactionHandler 回填。整个过程异步、有延迟、可能部分成交、可能被拒单。
config.json 的 live-paper 环境(Launcher/config.json:378-391)有个反直觉的配置:
378 // defines the 'live-paper' environment 379 "live-paper": { 380 "live-mode": true, 382 // the paper brokerage requires the BacktestingTransactionHandler 383 "live-mode-brokerage": "PaperBrokerage", 385 "setup-handler": "QuantConnect.Lean.Engine.Setup.BrokerageSetupHandler", 386 "result-handler": "QuantConnect.Lean.Engine.Results.LiveTradingResultHandler", 387 "data-feed-handler": "QuantConnect.Lean.Engine.DataFeeds.LiveTradingDataFeed", 388 "data-queue-handler": [ "QuantConnect.Lean.Engine.DataFeeds.Queues.LiveDataQueue" ], 389 "real-time-handler": "QuantConnect.Lean.Engine.RealTime.LiveTradingRealTimeHandler", 390 "transaction-handler": "QuantConnect.Lean.Engine.TransactionHandlers.BacktestingTransactionHandler" 391 },
L382 的注释 // the paper brokerage requires the BacktestingTransactionHandler 点破了——live-mode: true(实盘时钟),数据是实时的(LiveTradingDataFeed),但交易仍走模拟撮合(BacktestingTransactionHandler)。因为 PaperBrokerage 继承自 BacktestingBrokerage(Brokerages/Paper/PaperBrokerage.cs:29 的 public class PaperBrokerage : BacktestingBrokerage),它的撮合逻辑就是回测那套。对比 live-zerodha 的注释(L397)// real brokerage implementations require the BrokerageTransactionHandler——真实券商才需要 BrokerageTransactionHandler。
💡 钻取要点(高潮心法):
live-paper是个"混合模式"——用实盘的时钟和数据,用回测的撮合。这恰恰证明了 Lean 的解耦深度:时钟、数据、撮合三者各自独立可换。你能组合出"实盘数据 + 模拟撮合"这种纸面交易模式,靠的就是每个维度都有独立接口。这就是"统一"的真正含义——不是"只有回测和实盘两种模式",而是"任意组合 handler 的多维开关"。
BacktestingSetupHandler:从 config.json 读 starting-cash/start-date/end-date 等数值,设置到 algorithm,然后调 Initialize()。初始现金是用户在 config 里写的数字。
BrokerageSetupHandler(Engine/Setup/BrokerageSetupHandler.cs:282-289):
282 if (liveJob.Brokerage != "PaperBrokerage") 283 { 284 //Zero the CashBook - we'll populate directly from brokerage 285 foreach (var kvp in algorithm.Portfolio.CashBook) 286 { 287 kvp.Value.SetAmount(0); 288 } 289 }
真实券商环境会清零 CashBook,然后从券商 GetCashBalance() 拉真实余额填充。PaperBrokerage 不走这步(用用户配置的初始现金)。这是实盘装配的关键安全设计——绝不信任用户写的初始金额,以券商实际余额为准,否则会爆仓。
RealTime 双实现:BacktestingRealTimeHandler 的 SetTime(time) 把时钟设到数据时间(算法时间旅行);LiveTradingRealTimeHandler 用 DateTime.UtcNow 墙钟。两者的 ScanPastEvents 都补漏,但触发源不同。
引擎主体对回测/实盘并非"零感知"——有三个精确定义的分支点。其余全部统一。请记住这三个位置。
Engine/Engine.cs:113:
113 var synchronizer = _liveMode ? new LiveSynchronizer() : new Synchronizer();
Synchronizer(Engine/DataFeeds/Synchronizer.cs:30)回测按数据时间推进——数据流里下一个 TimeSlice 的时间戳就是当前时间,算法在历史数据里"时间旅行"。
LiveSynchronizer : Synchronizer(Engine/DataFeeds/LiveSynchronizer.cs:31-100)实盘按墙钟推进:
31 public class LiveSynchronizer : Synchronizer 39 { 40 private LiveTimeProvider _frontierTimeProvider; 60 _frontierTimeProvider = new LiveTimeProvider(realTime: TimeProvider); 100 var now = DateTime.UtcNow; // 墙钟驱动
L100 的 DateTime.UtcNow 是实盘的"时间源"。LiveSynchronizer 在墙钟到了下一个数据点的时刻才释放 TimeSlice,没数据就等。
💡 钻取要点:这是唯一一处引擎主体直接根据
_liveMode选择具体类的地方。但它选的不是 Handler(Handler 由 config 装配),而是 Synchronizer——时间推进器。Synchronizer 不在五大 Handler 之列(它是 Engine 内部的数据同步器),所以这个分叉点更像是"引擎内部的时间策略选择",而非"模式差异"。用户的QCAlgorithm完全看不到这个分支。
这是最核心的分叉点。回测环境(Launcher/config.json:366-376):
366 // defines the 'backtesting' environment 367 "backtesting": { 368 "live-mode": false, 370 "setup-handler": "QuantConnect.Lean.Engine.Setup.BacktestingSetupHandler", 371 "result-handler": "QuantConnect.Lean.Engine.Results.BacktestingResultHandler", 372 "data-feed-handler": "QuantConnect.Lean.Engine.DataFeeds.FileSystemDataFeed", 373 "real-time-handler": "QuantConnect.Lean.Engine.RealTime.BacktestingRealTimeHandler", 374 "history-provider": [ "SubscriptionDataReaderHistoryProvider" ], 375 "transaction-handler": "QuantConnect.Lean.Engine.TransactionHandlers.BacktestingTransactionHandler" 376 },
实盘环境(以 live-zerodha 为例,L394-407):
394 "live-zerodha": { 395 "live-mode": true, 398 "live-mode-brokerage": "ZerodhaBrokerage", 399 "data-queue-handler": [ "ZerodhaBrokerage" ], 401 "setup-handler": "QuantConnect.Lean.Engine.Setup.BrokerageSetupHandler", 402 "result-handler": "QuantConnect.Lean.Engine.Results.LiveTradingResultHandler", 403 "data-feed-handler": "QuantConnect.Lean.Engine.DataFeeds.LiveTradingDataFeed", 404 "real-time-handler": "QuantConnect.Lean.Engine.RealTime.LiveTradingRealTimeHandler", 405 "transaction-handler": "QuantConnect.Lean.Engine.TransactionHandlers.BrokerageTransactionHandler", 406 "history-provider": [ "BrokerageHistoryProvider", "SubscriptionDataReaderHistoryProvider" ] 407 },
对比两段,差异全在 data-feed-handler 与 transaction-handler——这是"数据源 + 成交"两个维度的切换。装配器(下一节讲的 Composer)读这些字符串,反射实例化成接口对象。引擎主体拿到的只是 IDataFeed/ITransactionHandler 接口引用,不知道具体类型。
_liveMode 分支AlgorithmManager.cs 里有几处根据 _liveMode 走不同逻辑,它们处理"回测与实盘在算法主循环里的细微差异":
L217(consolidator 扫描精度):
211 // will scan registered consolidators for which we've past the expected scan call. 212 // In live mode we want to round down to the second, so we don't scan too far into the future: 217 var pastConsolidatorsScanTime = _liveMode ? time.RoundDown(Time.OneSecond) : time;
实盘把扫描时间向下取整到秒,避免因为墙钟的毫秒抖动把"未来几百毫秒"的数据错误地塞进当前 K 线。回测按精确时间走。
L345(margin call 触发时机):
344 // perform margin calls, in live mode we can also use realtime to emit these 345 if (time >= nextMarginCallTime || (_liveMode && nextMarginCallTime > DateTime.UtcNow))
回测只在数据时间到了才检查强平;实盘额外允许"即使数据时间没到,只要墙钟已经过了预定时间也检查"——因为实盘可能短暂没数据,但不能因此漏掉强平。
L526(delisting 跟踪):
525 // Only track pending delistings in non-live mode. 526 if (!algorithm.LiveMode) 527 { 528 // Keep this up to date even though we don't process delistings here anymore
退市跟踪只在回测做——实盘退市由券商事件驱动,不在这里处理。
💡 钻取要点(高潮心法):这三处分支处理的都是"回测数据精确、实盘数据有抖动/缺失"带来的工程细节,跟用户的策略逻辑无关。用户写
OnData时完全不用关心这些——QCAlgorithm代码对运行模式零感知。这就是 Lean 设计的胜利:把所有模式差异压缩到引擎内部的几个工程点上,把它们留给引擎作者处理,用户的策略代码保持纯净。
把三个分叉点合起来看,统一性的全貌就清晰了:
统一性体现在三处代码对所有模式通用:
Program.cs:入口逻辑完全一样——读 config、调 Initializer.GetSystemHandlers/GetAlgorithmHandlers、new Engine、engine.Run。不区分模式。Engine.cs:除了 L113 选 Synchronizer,其余逻辑(装配算法、初始化各 Handler、跑 AlgorithmManager、收尾)对所有模式完全一样。AlgorithmManager.cs:除了 L217/345/526 三处工程细节,主循环(while 里收 TimeSlice → 调 OnData → 处理订单)对所有模式完全一样。模式差异完全收敛到 config.json 的 environment 字段。改一个字符串("backtesting" → "live-interactive"),就从一个模式切到另一个。这是 Lean 最引以为傲的特性——量化研究者写的策略,今天回测、明天上实盘,代码一行不改。
💡 钻取要点(高潮心法收束):"回测实盘统一"不是营销口号,而是依赖反转原则的工程落地。引擎主体(Program/Engine/AlgorithmManager)依赖接口(
IDataFeed/ITransactionHandler/...),不依赖实现(FileSystemDataFeed/BrokerageTransactionHandler/...)。回测与实盘的差异被封装在各自的实现类里,对引擎透明。下一节讲 Composer 怎么把 config 里的字符串变成接口实例——这是这套机制的最后一环。
Backtesting* 与 LiveTrading* 两套实现,命名规律清晰。FileSystemDataFeed(读磁盘,incredibly fast)vs LiveTradingDataFeed(实时,依赖 data-queue-handler)。IBrokerage+IDataQueueHandler(如 ZerodhaBrokerage)。live-mode: true + LiveTradingDataFeed + BacktestingTransactionHandler(因 PaperBrokerage 继承 BacktestingBrokerage),注释明确"paper brokerage requires the BacktestingTransactionHandler"。下一节,我们钻进 Composer——它用 MEF + 反射把 config.json 里的类名字符串变成接口实例,完成"回测实盘统一"的最后一环。