为什么我劝你自己写一个量化回测框架 1. 为什么我劝你自己写一个回测框架做了几年量化交易从最早啥都用现成框架到后面慢慢被逼着开始自己写回测引擎这个过程其实挺值得聊聊。好多人一听自定义量化回测框架就觉得没必要觉得市面上backtrader、zipline、vnpy这些开源框架一个比一个成熟直接用不就完了。我的回答是用但不够。自己写回测框架的核心好处不是造轮子这么简单而是让你真正搞懂一笔交易从信号产生、到订单撮合、到手续费扣除、到最后算绩效中间到底发生了什么。你用得越久越会发现开源框架给你封装好的黑盒往往也隐藏了大量对结果影响巨大的细节。比如它默认用K线收盘价撮合还是下一根开盘价拆单是怎么处理的停牌日子怎么跳过的这些问题框架文档可能压根不会告诉你,但恰恰决定了你的回测结果到底可不可信。这篇文章想写给正在学Python量化交易策略、对回测结果有疑惑、想真正掌控自己策略验证流程的人。我会把一套能扛住日常策略验证的自定义回测框架从架构设计到事件驱动引擎、交易成本模型、绩效归因再到我实际踩过的那些坑完整拆给你看。看完你不仅能搭出一个能跑的框架还能明白框架里每一个环节为什么这么设计这样你改起来、调起来心里才有底。2. 开始之前先想清楚你要回测的到底是什么策略2.1 策略类型决定框架设计方向我见过太多人一上来就写框架写到三分之一发现设计上根本支撑不了自己的策略需求只能推倒重来。所以动手之前先问自己一个问题我平时跑的策略属于哪一类主流量化交易策略大概分三类低频日线级策略持仓周期几天到几个月对撮合精度要求不高关注的是趋势判断和仓位管理。中频分钟级策略持仓周期从几分钟到几小时对滑点和手续费开始敏感回测时的撮合逻辑会影响最终结果。高频盘口级策略基于Tick数据做毫秒级决策散户和个人开发者建议不要碰。不是做不了而是需要的数据量、撮合精度、服务器部署成本都会让你怀疑人生而且高频回测结果和实盘的差距通常大到没有参考意义。我个人建议大多数个人量化交易者把目标聚焦在前两类就够了。做日线或分钟线级别的策略完全可以用一套结构清晰的框架来支撑。明确了这个框架设计的重点就很清楚了事件驱动的主循环、合理的交易成本模型、对停牌和涨跌停的处理这几块是核心。2.2 四层架构数据、策略、撮合、绩效我推荐的架构是拆成四层每一层只管自己的事情层与层之间通过标准接口通信。数据层负责行情数据的加载、清洗、复权处理输出统一格式的K线数据。策略层接收K线数据根据你的规则产生买入、卖出信号输出目标仓位。撮合层把信号转换成订单模拟成交逻辑扣除手续费和滑点维护持仓和资金。绩效层根据成交记录和资金曲线计算收益率、最大回撤、夏普比率、胜率等指标。为什么要这么分层因为每一层都是可以独立替换的。你今天用日线数据明天想改成分钟线只改数据层你想换一个更精细的撮合模型只改撮合层。策略层的代码不动绩效层也不动。我早期就是没想清楚这件事把买卖信号、成交判断、资金管理全部写在同一个循环里结果换数据频率的时候差点把整个代码重写一遍。另外层与层之间传递的数据结构要提前定好。我会用NamedTuple或者Pydantic模型来定义K线数据、订单、成交回报、持仓信息这些对象。这样做的好处是代码里到处都是类型提示写起来不容易漏字段排查问题也方便。2.3 技术选型pandas做分析numpy做计算再聊一下技术选型。我框架里的主力是pandas但计算密集的地方一定要转成numpy数组否则速度会让你崩溃。pandas的优势是数据清洗、分组聚合、滚动计算非常方便尤其是做K线数据对齐、计算指标这类操作时写起来很顺手。但pandas的瓶颈在于逐行遍历。如果你写for循环去遍历DataFrame里的每一行回测时会卡到怀疑人生。所以我的原则是数据处理和指标计算用pandas一次性向量化完成。事件循环里的撮合逻辑尽量用纯Python对象加numpy标量运算避免在循环里去切片DataFrame。如果数据量真的很大可以考虑把K线数据转换成numpy结构化数组再遍历速度会有明显提升。很多人把性能问题归咎于Python本身其实大部分情况是用法不对。vectorized操作和逐行循环的速度差距经常是几十倍在写框架的时候就要有意识去避免。3. 数据层实操K线数据到底要存什么、怎么处理3.1 数据格式设计的三个基本原则数据层是整个回测框架的地基这块处理不好后面全白搭。我建议K线数据的存储格式坚持三个原则第一时间戳统一用datetime类型不要用字符串。原因很简单你后面要按时间做对齐、切片、重采样字符串的比较和计算都很别扭。第二价格数据全部用float不要用Decimal。虽然Decimal精度更高但速度慢而且回测中你根本不需要那么高的精度float的误差在正常回测周期内完全可以忽略。国内股票价格两位小数、期货价格也有最小变动价位用float足够。第三每条K线必须包含open、high、low、close、volume这五个基本字段缺一不可。为什么因为很多指标计算需要用到最高价和最低价比如ATR、唐奇安通道成交量在判断突破有效性时也必不可少。我见过有人为了省内存只存close后面写策略的时候发现一堆指标算不了后悔莫及。一个基本的K线数据对象我建议这样定义from dataclasses import dataclass from datetime import datetime dataclass class Bar: symbol: str datetime: datetime open: float high: float low: float close: float volume: float框架内部建议用pandas的DataFrame来存储整段历史数据列名就对应这几个字段然后用datetime作为索引。这样天然支持时间切片、重采样而且速度也不慢。3.2 前复权和后复权选错直接废掉策略数据层最容易翻车的点就是除权除息的处理。很多人从数据源拉下来价格直接丢进回测框架结果遇到分红送股的时间点价格出现跳空策略信号就会乱掉。复权分两种后复权以最早的价格为基准把后面的价格往上调整。好处是历史价格不会变化你每次拉数据结果一致适合做因子分析和长期回测。前复权以最新价格为基准把历史价格往下调整。好处是价格和当前真实市场价接近看起来直观但缺点是每次除权后历史数据都要重新算一遍。做量化交易策略回测我强烈建议统一使用后复权数据。原因很简单回测结果要稳定可复现。前复权数据一旦有新除权发生整个历史数据都会被修改你的回测结果也跟着变这会导致策略参数优化、结果比较都变得不可靠。另外有一点很多人容易忽略后复权价格和真实交易价格不一样所以在计算收益率的时候通常用收益率序列而不是价格序列。回测结束后如果要换算成现在的价格视角去看再做一次前复权处理就行。4. 事件驱动引擎整个框架的心脏4.1 为什么不用向量化回测而要用事件驱动这个问题经常有人问。向量化回测就是把整个策略逻辑一次性作用在整个价格序列上看起来代码非常简洁但有几个硬伤第一它没法处理顺序相关的逻辑。比如昨天的止损价取决于今天开盘是否触发这种条件分支在向量化里很难表达。第二它没法处理多标的同时决策的问题。第三也是最关键的向量化回测和实盘代码之间的鸿沟太大。你在向量化回测里写出的策略逻辑到了实盘全部要重写等于双重工作量。事件驱动的优势在于每个K线到来时策略层只处理当前这一根Bar该干什么订单一笔一笔处理状态一步一步更新。这种模式和你实盘时程序收到的行情推送基本上是同一套逻辑。虽然写起来代码更啰嗦但你能真正确认每一笔交易是怎么发生的而且策略代码改造成实盘也更顺畅。4.2 事件类的设计简单但要够用事件驱动引擎的核心是事件。我的框架里定义了这样几类事件BarEvent新的K线数据到达。SignalEvent策略产生了一个买卖信号。OrderEvent风控或资金管理模块生成了订单。FillEvent订单被撮合成交。每个事件只需要携带必要的信息。比如SignalEvent我建议不要直接放买几手这样具体的数字而是放目标仓位或者方向意图比如目标仓位70%或者以接近收盘价买入。为什么因为什么时候买、以什么价格买、买多少其实是不同模块的职责。策略只负责给出方向和力度具体的订单执行由后面的模块去计算。这里也要强调一点事件本身不要附带数据而是附带索引或引用。比如BarEvent里包含的是symbol和datetime而不是把整个Bar的数据拷贝进去。如果事件携带了大的数据对象循环中的内存拷贝和垃圾回收会影响性能数据多了以后会明显变慢。4.3 主循环怎么写每个Bar一步步推进主循环的逻辑其实不复杂核心就是沿着时间轴逐根K线推进把数据喂给策略策略产生信号后转成订单订单再撮合成交更新资金和持仓。一个简化版本的主循环代码大概长这样def run_backtest(data, strategy, broker): for i in range(len(data)): bar data.iloc[i] # 1. 数据推给策略策略决定要不要调仓 signal strategy.on_bar(bar) # 2. 如果策略给出了信号生成订单 if signal is not None: order strategy.signal_to_order(signal, bar) # 3. 交给撮合模块尝试成交 fill broker.place_order(order, bar) # 4. 如果成交了更新持仓 if fill is not None: broker.update_position(fill) # 5. 每个bar结束后记录账户净值 broker.snapshot(bar.datetime)这里有一个非常重要的设计细节策略是在当前这根Bar收盘后产生信号但订单通常要等到下一根Bar才执行。为什么因为为了防止未来函数——你在当前Bar收盘的瞬间就成交在当前Bar的价格等于偷看了这根K线的最终价格这在实盘里是做不到的。我在实际开发中踩过这个坑一开始在on_bar里直接以当前Bar的close成交回测曲线非常漂亮但实盘一跑就露馅了连续几笔滑点吃掉大部分利润。后来我改成信号产生后下一根Bar的开盘价或者均价成交回测结果才终于和实盘对得上。4.4 订单状态管理别把逻辑全堆在一个循环里当你的框架扩展到可以处理多标的、分批建仓、止损止盈这些场景时订单状态管理会变得复杂。我建议维护一个订单状态机每个订单有这几个状态PENDING等待成交、FILLED已成交、CANCELLED已撤销、EXPIRED已过期。一个订单发出后不要假设它一定能成交。遇到停牌、涨跌停、流动性不足的情况订单可能一直挂着或者部分成交这些都要在框架里明确处理。比如我处理停牌的方法是当订单发出后如果标的停牌没成交订单状态保持PENDING复牌当天自动尝试重新成交。这样能防止框架在持仓和资金记录上出现错乱。如果你想在这个阶段继续进阶还可以加入母子订单的概念一个建仓信号对应一个母订单实际执行时拆成多个子订单分几天进场。这样就把仓位管理从策略逻辑里解耦出来了。不过这个属于进阶玩法先用简单的方式跑通整个流程更重要。5. 交易成本模型回测和实盘的差距都在这里5.1 手续费不是一个固定数要分市场、分品种交易成本模型的好坏直接决定了你的回测结果有没有参考价值。很多新手在这里偷懒手续费统一设个万二、万三滑点干脆设为0结果回测年化收益率高得吓人实盘一跑收益腰斩。手续费的计算方式不同市场差异很大。A股股票的费用包括佣金往往是万2.5到万3但有最低5元限制、印花税卖出时收取目前是千分之0.5、还有过户费。期货则是按手数或按成交金额的万分之几收取保证金率也各不相同。我的建议是在框架里抽象一个CommissionModel接口不同的品种实现不同的计费方式。回测前先打印一份手续费的模拟计算表比如委托买入10万元股票实际费用是多少卖出时又是多少。用具体数字和实盘券商结算单对一遍确保成本和真实情况一致再开始回测。5.2 滑点模型设置多少才合理滑点的本质是市场冲击成本和流动性成本的综合反映。日内交易和小盘股滑点通常更大大盘股和期货主力合约滑点相对小一点。我框架里实现了两种滑点模型。一种是固定滑点简单粗暴股票设成交价的0.1%期货设1个最小变动价位。另一种是百分比滑点按照当前价格的百分比计算。对于策略验证期来说固定滑点模型已经够用了等后面需要更精细评估再升级。这里给一个参考值日线级别的策略保守起见双边滑点至少设0.1%到0.2%。如果回测结果在加了这部分成本后依然能赚钱实盘才有较高的置信度。记住回测是用来发现问题的不是用来证明自己有多厉害的把成本设得太低最后害的是自己。5.3 涨跌停和停牌很多人容易忽略的暗坑A股有涨跌停制度期货有涨跌停板和强制减仓这些特殊情况如果不做处理回测结果和实盘会有巨大出入。比如你的策略在涨停板上发出买入信号回测框架直接按涨停价成交了但实际上涨停板买单堆积如山你根本排不到单。我处理涨跌停的逻辑是如果当日收盘价等于涨停价且你发出的是买入订单则订单标记为未成交等待后续K线重新尝试反之如果收盘价等于跌停价卖出订单同样无法成交。这样处理之后回测结果会显著变保守但和实盘更贴近。停牌的处理更简单数据层在清洗数据的时候就把停牌期间的空缺K线删除事件循环到这段时间自动跳过同时框架要保证持仓记录不会因为停牌期间没有行情数据而出错。6. 绩效分析让回测结果说服你自己6.1 核心绩效指标怎么算才不失真回测跑完之后绩效分析模块会输出一组核心指标。我重点看的几个是年化收益率、最大回撤、夏普比率、胜率、盈亏比、卡玛比率。它们各有各的坑我给你逐个说一下。年化收益率不要用简单的收益除以年限而要用几何收益率的年化方式计算。比如两年总收益是21%年化应该用1.21的0.5次方减1得到10%而不是简单的10.5%。资金曲线的daily return序列算出来之后年化收益率的公式是daily_returns equity_curve.pct_change().dropna() total_return (1 daily_returns).prod() - 1 annual_return (1 total_return) ** (252 / len(daily_returns)) - 1最大回撤的计算也有坑它衡量的是从任何一个峰值跌到最低点的最大幅度。我遇到过一个情况一个策略的回撤曲线在实盘和回测里的最大回撤明明相同但持续时间完全不同——回测里回撤只持续了两周实盘却持续了四个月。所以我在绩效报告里除了最大回撤之外还要输出最长回撤恢复时间。这个指标对实盘心态的影响比回撤比例本身更大。夏普比率同样不能只看一个数。它是年化收益减去无风险利率再除以年化波动率。在日线数据的回测框架里用252个交易日做年化系数是基本操作。无风险利率用2%或者国债收益率都可以但别设成0否则夏普比率会偏高。我一般同时输出Sortino比率它只惩罚下行波动对非正态分布的策略更友好。6.2 成交记录的细节审计别只看资金曲线资金曲线是结果但我不建议只盯着它看。一定还要导出每一笔交易的成交明细包含进场时间、进场价格、出场时间、出场价格、持仓天数、盈亏金额。然后逐笔去核对几笔关键交易尤其是亏损最大的那几笔。为什么这么做因为资金曲线会掩盖很多问题。比如一个策略总收益是正的很漂亮但仔细看成交记录可能会发现它赚钱全靠某一次极端行情其余时间全在亏手续费。这种情况说明策略逻辑存在严重的幸存者偏差而不是什么截断亏损让利润奔跑。我自己审计成交记录时常用的一个操作是把每笔交易的持仓时间分布画出来看这个策略到底是交易频率高的一把梭哈型还是低频率的坚定持有型。持仓时间分布和策略预期不一致的时候大概率策略逻辑有问题值得深挖。同样重要的是对交易成本的敏感性分析。我跑完回测后会把手续费和滑点分别放大一倍再跑一遍。如果放大后策略从盈利变成亏损说明策略的利润来源对交易成本高度敏感实盘风险偏大。反过来如果成本放大后策略依然能赚钱那实盘落地的把握就大得多。7. 常见问题排查与调试心得7.1 典型问题速查表我在开发框架过程中踩过不少坑这里整理一个速查表供你对照排查问题现象可能原因排查思路回测收益率过高年化超100%用了未来函数信号和成交在同一个Bar检查成交价是否用了信号K线之后的行情回测和实盘差距巨大手续费和滑点没算或算得太低输出每笔交易的成本明细对比券商成交记录资金曲线出现阶梯状跳升除权除息没做复权处理改用后复权价格重新回测某段时间资金曲线横盘不动持仓标的停牌订单一直未成交检查停牌期间的订单状态日志连续亏损但总收益为正收益集中在少数极端行情逐笔审计成交记录查看盈利贡献分布信号很多但成交很少涨跌停导致订单无法成交检查撮合模块的涨跌停判断逻辑结果每次回测都不一样数据源每次都会更新前复权价格统一使用后复权价格或固定数据版本7.2 日志系统回测框架必须具备的调试利器调试回测框架和调试普通业务代码最大的不同在于你面对的是大量重复的循环逻辑很难靠print定位问题。我建议从第一天就给框架加上分级日志系统而不是等出问题再补。我的日志设计分三个级别INFO级别记录哪些信号产生、订单是否成交WARNING级别记录滑点超过预期、订单部分成交这类异常情况ERROR级别记录数据缺失、资金不足等严重错误。所有日志都带上时间戳和标的代码这样出问题的时候才能快速定位到是哪一天、哪个标的上发生了什么。日志还有一个特别重要的用途验证策略逻辑和实盘的一致性。你把回测的日志和实盘交易的日志做对比就能确认策略代码在回测和实盘环境下是否执行了同样的逻辑。这个工作看似繁琐但能帮你过滤掉大量回测很好、实盘就跑偏的经典问题。7.3 单元测试给框架上保险最后聊一下单元测试。很多自己写回测框架的人没有测试习惯觉得跑起来没报错就万事大吉。但回测框架的bug往往不是直接报错而是静默地算出错误结果。比如订单在涨跌停时错误成交了或者手续费少算了这些都不会让程序崩溃但结果已经错了。我给自己定的规矩是每写一个核心模块必须配套写至少一个单元测试。覆盖的场景包括单笔买卖交易的资金变化、手续费计算、滑点计算、持仓成本的计算、回撤计算的正确性。给你一个例子我测试撮合模块时会构造一段很短的行情数据比如5根K线确保买入、卖出、止损这些基础逻辑都能走到然后断言最终的资金金额等于预期。这些测试跑起来极快但能保证框架改参数、调整逻辑的时候不会改坏核心功能。最后分享一点实操体会这篇文章从一个要不要自己写回测框架的疑问讲起把框架的四层架构、事件驱动引擎、交易成本模型、绩效分析模块以及调试方法都梳理了一遍。整个框架写下来代码量大概在几百行到一千行左右对熟悉Python的人来说不算大工程但带来的认知提升是实打实的。我个人在实际操作中最深的感受是写回测框架的过程很大程度上是在逼你想清楚自己交易策略的每一个细节。以前用现成框架的时候很多参数都是默认值很多逻辑是黑盒结果只是看那个最终收益数字。现在自己写了框架每一笔成交的成本都看得明明白白每一个延迟成交的原因都清清楚楚这种掌控感是直接调用现成框架完全体会不到的。最后再提醒一句不管是自研框架还是开源框架回测结果都不能说明全部问题。真实市场里的流动性变化、投资者情绪、政策影响这些因素任何回测模型都没办法完全模拟。回测框架的最终价值是帮你筛选出那些在合理假设下依然表现出色的策略而不是给你一个实盘躺赚的保证。保持这个心态你的框架才能发挥它真正的作用。