资讯动态

自研Python量化回测框架:核心模块详解与实战避坑

发布时间:2026/8/30 17:38:45 来源:尧图企业网站定制
做量化这几年我越来越觉得“回测框架”这东西属于典型的用现成的觉得隔靴搔痒自己写又怕麻烦。用过Backtrader、也试过vectorbt最后兜兜转转还是动手写了套自己的Python量化回测框架。今天这篇就把我踩过的坑、走过的弯路、以及最终的框架设计思路完整梳理一遍给同样在量化交易路上折腾的朋友一个参考。这篇内容适合谁如果你已经熟悉Python基础语法、看过不少量化策略文章但对“策略背后那套回测系统到底怎么运转”还是一知半解那这篇文章正好帮你把这块拼图补上。我会从框架的整体设计思路讲起然后逐步落到数据容器、策略接口、撮合逻辑、资金管理和指标计算的核心代码实现最后把我在实际调试过程中遇到的那些“看起来是策略问题、其实是框架问题”的典型案例讲透。全程说人话不堆术语。1. 为什么还要自己造轮子现有框架的局限1.1 回测框架到底在解决什么问题说白了回测就干三件事拿历史数据、按你的策略规则模拟交易、最后算一笔账看赚不赚。听起来简单但这里面每一步都有无数细节。历史数据要不要复权订单是收盘价成交还是开盘价成交手续费和滑点算多少持仓是按市值还是按手数这些问题如果不在一套严谨的框架里处理干净回测结果就跟开盲盒差不多看着很漂亮一上实盘就露馅。所以我理解的量化回测框架本质上是一套“交易规则的沙盒”。它把你的策略指令翻译成订单把订单放进一个可重复的历史场景里撮合再把成交结果汇总成指标。这个沙盒的规则越接近真实市场回测的可信度就越高。而市面上的通用框架往往在这个“接近真实市场”的尺度上做妥协。1.2 现成框架的“够用”与“不够用”我拿Backtrader举个例子。它功能确实全社区生态也好网上案例一抓一大把。但用久了你会发现几个别扭的地方。第一它的策咯生命周期控制很繁琐next方法的调用时机、指标预计算、订单状态回调绕来绕去调试一个复杂组合策略时心智负担特别重。第二它的数据格式绑定比较死想接入自己清洗过的数据源时经常要做一层适配器转换反而多了一道工序。第三也是最重要的一点它很难实现一些非标准撮合逻辑。比如我想模拟“如果涨停板就买不进”这种情形或者要模拟分批建仓、成交部分成交的场景改起来就比较费劲。当然我不是说现成框架不好。对于学习回测概念、快速验证一个简单思路Backtrader完全够用。但当你开始研究更精细的策略逻辑、需要完全掌控每一步撮合过程的时候一套自己设计的、逻辑透明的事件驱动框架会让你舒服得多。收益是可控性代价是开发时间这笔账我觉得值。而且自己写框架最大的隐性收益是你会彻底搞懂回测结果的每一个数字是怎么算出来的这对后续看清策略优劣、避免自欺欺人太关键了。2. 整体设计思路先想清楚骨架再写代码在动笔写第一行代码前我想先把框架的骨架画清楚。回测框架的典型流程可以拆成一条流水线数据加载 - 策略信号生成 - 订单创建 - 撮合成交 - 持仓与资金更新 - 绩效统计。这六个环节里数据和策略是输入绩效是输出中间的订单、撮合、资金共同构成了“模拟交易环境”。我选择把框架设计成事件驱动模型而不是纯向量化计算。原因很直接策略逻辑是复杂的、有状态的比如“持仓超过5天就不再加仓”这种判断向量化模型很难优雅地表达这种逐bar状态变化但事件驱动用循环逐根K线推进思维上与真实交易完全一致。代价是性能不如纯向量化快但对我们大部分策略场景来说几千根K线、几万次循环Python完全扛得住优化空间留到后面再说。模块划分上我坚持了几条设计原则数据层与策略层解耦策略不关心K线是从CSV读的、还是从数据库查的只消费统一的数据接口。策略层与撮合层解耦策略只负责“想买卖什么”不负责“能不能成交”。想买多少钱、能成交多少是broker的事。组合与撮合解耦撮合只管理订单状态、成交量、成交价资金管理负责更新账户净值、持仓市值、可用现金。让每一个模块都做且只做一件事回头加新功能、修bug都特别省力。下面逐个模块拆开聊。2.1 核心模块拆解从K线到订单的完整链路先说清楚每个模块的职责边界。DataFeed负责K线数据的读取与遍历。内部维护一根“当前bar”的指针对外暴露类似current()的方法返回当前时间点的行情快照。数据源可能是本地CSV、Parquet、数据库也可能是实时行情接入实盘时用。在回测里它就是一个内存中的DataFrame或者对象列表。Strategy策略基类定义了用户需要实现的回调接口。核心就两个on_init()策略初始化比如加载参数、预计算指标和on_bar()每来一根新K线时调用策略在这里决定要不要下单。策略内部可以持有自己的状态比如当前持仓目标、上次开仓时间之类的。Broker撮合器接收策略创建的订单按撮合规则生成成交回报。它决定了订单是马上成交、延迟到下一个bar成交、还是部分成交。这个模块是框架的“心脏”也是做回测时最容易糊弄、但最不能糊弄的地方。Portfolio组合账户维护现金、持仓、交易记录。每次撮合成功后更新现金和持仓每根K线结束时根据收盘价计算账户总净值存到净值序列里。Analyser最后用净值序列计算绩效指标比如年化收益率、最大回撤、夏普比率、胜率、盈亏比等等。这种“一个模块管一件事”的分工让整个回测流程变得完全可观测每一笔订单从创建到成交每一个状态变更都在账户里有痕迹。出了问题很容易顺着链路定位到具体环节。2.2 事件驱动还是向量化两种回测范式怎么选这是设计阶段绕不开的第一个岔路口。向量化回测的思路是把策略信号一次性算好然后对全量数据做矩阵运算。比如“当5日均线上穿20日均线时买入”这种规则用pandas几行就写完了。好处是代码短、运行极快缺点是策略逻辑一复杂就力不从心。比如“逐bar判断是否触发止盈止损”、“基于持仓时间调整仓位”这类带状态、带循环依赖的逻辑向量化写起来很容易出错而且错误往往非常隐蔽。事件驱动回测的思路是一根K线一根K线地推模拟真实交易的时间推进。每根bar时策略读当前状态可能发出订单broker撮合账户更新然后进入下一根bar。逻辑上就是真实交易的慢放版。代码量更大、运行慢一截但表达能力几乎不限。我的选择是以事件驱动为主但部分重计算环节比如技术指标可以用pandas批量算好再喂给框架。这样既保留了策略表达的灵活性又能利用向量化运算的速度优势。框架只负责“交易模拟”不负责“指标计算”各取所长。2.3 数据容器设计为什么自定义BarData比字典更可靠新手阶段我特别爱用字典存K线数据{open: 10.0, high: 10.5, ...}后来被坑惨了。字典的键是字符串一旦打错比如data[colse]程序不会直接报错而是返回一个KeyError在回测过程里要费劲排查。更麻烦的是字典没法优雅地支持方法和属性操作代码写起来又长又啰嗦。后来我换成了自定义的BarData类用具名属性bar.close、bar.high。这样IDE自动补全友好拼写错误在写代码时就能发现。内部实现上为了性能还加了__slots__限制属性绑定省内存、提速。别小看这个设计框架跑几千只股票、几万根K线时这个细节能明显减少内存占用。class BarData: __slots__ [symbol, datetime, open, high, low, close, volume, turnover] def __init__(self, symbol, datetime, open_, high, low, close, volume, turnover0.0): self.symbol symbol self.datetime datetime self.open open_ self.high high self.low low self.close close self.volume volume self.turnover turnover def __repr__(self): return fBar {self.symbol} {self.datetime} O:{self.open} C:{self.close}顺便说一句K线的OHLC字段在盘后做回测时天然存在“用未来数据”的风险。因为一根日K的high、low、close是整个交易日结束才知道的如果你的策略在“当天盘中”下单却用了“当天收盘才确定的收盘价”这就是前视偏差。后面我会专门讲这个坑怎么避免。3. 实战实现写一个最小可用的回测引擎说一千道一万不如直接看代码。下面这套框架是我实际在用的一个精简版本麻雀虽小五脏俱全可以直接跑通一个简单的双均线策略。3.1 先定好策略接口写策略的人只关心什么策略接口是最需要反复斟酌的部分因为策略模块是用户接触最多的部分它的易用性直接决定框架的体验。我把策略拆成两个文件一个基类一个示例策略。基类只做一件事定义策略的生命周期方法并提供下单接口。# base_strategy.py from abc import ABC, abstractmethod class StrategyBase(ABC): 策略基类。 继承者需要实现 on_init 和 on_bar 两个回调。 通过 self.buy() / self.sell() 发出下单请求。 def __init__(self): self.data_feed None # 数据源对象由引擎注入 self.broker None # 撮合器对象由引擎注入 self.portfolio None # 账户对象由引擎注入 self.indicators {} # 存放自定义指标的字典 abstractmethod def on_init(self) - None: 回测开始前的初始化工作加载参数、准备指标计算等。 pass abstractmethod def on_bar(self, bar: BarData) - None: 每根K线到来时调用在这里写策略逻辑。 pass def buy(self, size: float, price: float 0.0, tag: str ) - None: 发出买入指令。size为股数/张数price0表示市价单。 order Order(symbolself.data_feed.symbol, directionbuy, sizesize, priceprice, tagtag) self.broker.place_order(order) def sell(self, size: float, price: float 0.0, tag: str ) - None: 发出卖出指令。 order Order(symbolself.data_feed.symbol, directionsell, sizesize, priceprice, tagtag) self.broker.place_order(order)这里我故意把下单接口统一成buy(size, price)和sell(size, price)price缺省为0表示市价单。策略作者只需要在on_bar里判断“要不要买、买多少”完全不接触订单状态机、成交回报这些底层机制。这种接口设计的核心原则是让策略作者只思考策略本身不要被框架细节绑架。示例的双均线策略也很简单# demo_strategy.py class DualSMAStrategy(StrategyBase): def __init__(self, fast_window: int 5, slow_window: int 20): super().__init__() self.fast_window fast_window self.slow_window slow_window self.prices [] def on_init(self): self.prices [] self.position 0 # 0空仓1持仓 def on_bar(self, bar: BarData): self.prices.append(bar.close) if len(self.prices) self.slow_window 1: return # 用当前的快慢均线判断金叉死叉 fast_ma sum(self.prices[-self.fast_window:]) / self.fast_window slow_ma sum(self.prices[-self.slow_window:]) / self.slow_window prev_fast sum(self.prices[-self.fast_window - 1:-1]) / self.fast_window prev_slow sum(self.prices[-self.slow_window - 1:-1]) / self.slow_window # 金叉进场注意只在当前bar收盘后确认下一根bar开盘执行 if prev_fast prev_slow and fast_ma slow_ma and self.position 0: self.buy(size1000, taggolden_cross) self.position 1 # 死叉离场 elif prev_fast prev_slow and fast_ma slow_ma and self.position 1: self.sell(size1000, tagdead_cross) self.position 0你注意到没有这里的金叉判断用的是当前bar的收盘价算出来的均线值但信号真正被broker撮合要等下一根bar。这个细节非常关键它能天然避免“用当前K线信号、又用当前K线价格成交”的前视偏差。在代码实现上这就是事件驱动框架的优势——你可以精确控制信号产生时间和成交时间之间的时序关系。3.2 撮合逻辑市价单、限价单在不同bar里的处理撮合器是回测框架里最需要抠细节的地方。我把它拆成两层第一层处理订单调度第二层处理逐笔撮合规则。先定义订单的数据结构from dataclasses import dataclass from enum import Enum class OrderStatus(Enum): PENDING pending FILLED filled REJECTED rejected class OrderDirection(Enum): BUY buy SELL sell dataclass class Order: symbol: str direction: OrderDirection size: float price: float # 0表示市价单 tag: str status: OrderStatus OrderStatus.PENDING filled_price: float 0.0 filled_time: object None撮合器的核心逻辑我按“市价单”和“限价单”分两条路径处理。这个是我实际调框架时觉得最值得展开的细节。市价单的处理市价单的特点是“不挑价格、只求成交”。在回测里怎么给它定价最合理的方式是策略在bar T收盘后发出信号订单在bar T1的开盘时以开盘价成交。因为它反映了一个很现实的交易场景你看到今天收盘的信号明天开盘去挂单买入。用开盘价已经是很理想的情况了实盘中你很可能要付出比开盘价更差的价因为滑点。class Broker: def place_order(self, order: Order): order.status OrderStatus.PENDING self.pending_orders.append(order) def process_bar(self, bar: BarData): 每根bar调用一次处理所有待成交订单。 if not self.pending_orders: return for order in self.pending_orders: if order.direction OrderDirection.BUY: # 市价单以当前bar开盘价成交 fill_price bar.open fill_size order.size elif order.direction OrderDirection.SELL: fill_price bar.open fill_size order.size order.status OrderStatus.FILLED order.filled_price fill_price order.filled_time bar.datetime self.portfolio.on_order_filled(order) self.pending_orders.clear()注意这里的process_bar是由主循环在每个bar开盘时调用的。也就是说订单在信号产生的bar收盘后被接受而在下一根bar的一开始被处理。这个时序在后面的BacktestEngine主循环里会体现。限价单的处理限价单稍微复杂一点。买单限价的意思是“我只愿意在低于等于某个价格时买”所以只要当前bar的最低价low触及限价就可以认为成交了。卖单则是最高价high触及限价时成交。但还有一个细节点成交价按限价算、还是按bar的open/high/low算最保守的做法是按限价成交因为你挂的是限价单价格再好也只以限价优先。不过实际中如果开盘就跳空直接穿过了限价你可能以开盘价成交更优。这里涉及到模型选择的问题不同框架有不同处理方式。我的处理方式比较简单def process_limit_order(self, order: Order, bar: BarData): if order.direction OrderDirection.BUY and bar.low order.price: # 保守起见按限价算成交价 fill_price order.price fill_size order.size return fill_price, fill_size if order.direction OrderDirection.SELL and bar.high order.price: fill_price order.price fill_size order.size return fill_price, fill_size return None, None为什么选择限价作为成交价而不是开盘价因为我希望回测结果偏保守宁可少赚一些“意外的跳空收益”也不要高估策略表现。做量化嘛回测结果差一点没关系别骗自己就行。后面如果你想让框架更精细可以改成“开盘跳空后按开盘价成交、否则按限价成交”那个撮合结果会更接近真实盘但代码复杂度也上来了。3.3 资金与持仓管理撮合之后怎么记账订单成交了接下来就是更新资金账户。这里有个容易被新手忽略的概念可用现金和持仓市值是两回事。买股票花掉的是现金持仓股票带来的浮动盈亏只有平仓或结算时才变成现金。回测时我们要同时维护这两部分。class Portfolio: def __init__(self, init_cash100000.0, commission_rate0.0003, slippage0.0): self.init_cash init_cash self.cash init_cash self.position 0 # 持仓数量这里简化成单一标的、不带杠杆 self.commission_rate commission_rate self.slippage slippage self.equity_history [] # 每日净值列表 self.trade_records [] # 成交记录列表 def on_order_filled(self, order: Order): 订单成交后的账户更新。 # 先计算税费和滑点 commission order.filled_price * order.size * self.commission_rate # 滑点影响成交价 slip_price order.filled_price * (1 self.slippage) if order.direction OrderDirection.BUY else order.filled_price * (1 - self.slippage) cost slip_price * order.size if order.direction OrderDirection.BUY: self.cash - (cost commission) self.position order.size elif order.direction OrderDirection.SELL: self.cash (cost - commission) self.position - order.size self.trade_records.append({ time: order.filled_time, direction: order.direction.value, size: order.size, price: slip_price, commission: commission, tag: order.tag, }) def update_equity(self, bar: BarData): 每根bar收盘后更新账户总资产。 # 持仓市值按收盘价估算 market_value self.position * bar.close equity self.cash market_value self.equity_history.append({ datetime: bar.datetime, equity: equity, cash: self.cash, position_value: market_value, })几个关键设计点的解释手续费率我默认设置是万三0.0003这是A股常见的佣金水平。但真实交易里还有印花税卖出时收取和过户费如果你做A股回测最好把卖出印花税也一并考虑进去。这个参数我后面还会提它对你的回测结果影响远超想象。滑点我设置了一个可配置的滑点比例默认0。但实际使用中我会设置成一个很小的正数比如0.0005万五。为什么因为回测里如果完全不考虑滑点你的成交价永远是最优的bar内价格这在实盘中几乎不可能实现。update_equity 的调用时机每根bar收盘后调用一次记录当天收盘时的账户总资产。这套代码天然基于“日线级别”记录净值如果你的框架要做分钟级别回测记得把这个函数改成每隔n分钟记录一次否则净值序列会过大。3.4 主循环把数据喂给策略再把订单喂给撮合现在所有零件都有了只差一个“总装”把它们拼起来。这个主循环是整个框架的发动机控制着时间推进的节奏。class BacktestEngine: def __init__(self, data_feed, strategy_class, strategy_paramsNone): self.data_feed data_feed self.strategy strategy_class(**(strategy_params or {})) self.broker Broker() self.portfolio Portfolio(init_cash100000.0) # 把核心对象注入策略 self.strategy.broker self.broker self.strategy.portfolio self.portfolio self.strategy.data_feed self.data_feed def run(self): self.strategy.on_init() for bar in self.data_feed: # 1. 先处理上一根bar留下的订单以新bar开盘价成交 self.broker.process_bar(bar) # 2. 让策略看到当前bar决定是否发新订单 self.strategy.on_bar(bar) # 3. 收盘后更新净值 self.portfolio.update_equity(bar) return self.portfolio.equity_history, self.portfolio.trade_records看到主循环的执行顺序了吗这是整个框架里最重要的一行时序先撮合旧订单上一根bar收盘后发出的买单在当前bar开盘时成交。再看新行情策略在当前bar收盘后发出新订单这些订单不会立刻成交而是等到下一根bar开盘才处理。最后更新账户用当前bar的收盘价给持仓估值更新净值曲线。这个顺序保证了策略在下单时永远看不到“订单成交后的持仓变化”。也就是说策略在on_bar里做判断时用的是“当前收盘时的信息”而下单之后要等到“下一个交易日开盘”才成交。这在逻辑上完全符合真实交易场景。很多新手回测结果虚高原因就是在这个时序上出了问题——策略用当前bar的信号又假设自己能在当前bar的收盘价成交。一天之内既不确认信号、又能立刻成交这在真实世界里是不可能做到的。4. 回测指标计算别只盯着收益率框架能跑出净值序列以后接下来的问题就是怎么评价这套策略。很多人只看一个“累计收益率”这非常危险。一个策略可能三年翻了三倍但中间回撤了60%这种策略你敢实盘吗大概率在回撤期就被自己割掉了。所以我建议至少算四个指标累计收益率、年化收益率、最大回撤、夏普比率。有条件的话再加上胜率和盈亏比。4.1 指标到底怎么算代码级别的实现指标计算可以直接用pandas来算效率高不少。但为了把细节讲透我用基础Python实现一遍让大家看清楚每个数字背后的含义。import numpy as np def calculate_metrics(equity_history, init_cash, periods_per_year252): equity_history: [{datetime, equity, ...}, ...] periods_per_year: 年K线数量日线252周线52 equities np.array([e[equity] for e in equity_history]) days len(equities) # 1. 累计收益率 total_return (equities[-1] / init_cash) - 1 # 2. 年化收益率复利 years days / periods_per_year annual_return (equities[-1] / init_cash) ** (1 / years) - 1 # 3. 最大回撤 peak np.maximum.accumulate(equities) drawdown (peak - equities) / peak max_drawdown drawdown.max() # 4. 夏普比率基于每期收益率 returns np.diff(equities) / equities[:-1] excess_returns returns - 0.02 / periods_per_year # 无风险利率按2% sharpe np.mean(excess_returns) / np.std(excess_returns) * np.sqrt(periods_per_year) if np.std(excess_returns) 0 else 0.0 # 5. 胜率与盈亏比从trade_records计算逻辑类似 # ... return { total_return: total_return, annual_return: annual_return, max_drawdown: max_drawdown, sharpe: sharpe, }最大回撤这个指标我多说两句。它衡量的是从曾经的最高点到最低点的最大跌幅代表的是策略最坏情况下“浮亏多少”。回测时不看最大回撤就相当于开车不看仪表盘只顾着踩油门往前冲。夏普比率是每一单位风险的超额收益补偿。大于1算及格大于2算优秀。但要注意夏普比率在策略收益不是正态分布的时候会失真如果你做的策略带期权、带非线性结构那夏普比率只能做参考不能做唯一标准。4.2 过拟合与被曲线拟合哪些信号说明回测不可信指标算出来之后更大的坑是在评价环节。我在团队里见过太多人拿着一个回测曲线兴奋地说“你看这个策略厉害吧”结果一上实盘就废。这里面的问题往往不是策略本身而是回测过程被过拟合了。一组典型的“危险信号”参数极度敏感把均线窗口从20改成21策略就从年化80%变成年化5%。这说明策略在“记忆”历史走势而不是在“识别”规律。真正的稳定策略参数微调时结果应该变化不大。交易次数极少但收益极高三年只做了5笔交易赚了200%。这不是策略厉害这是运气好撞上了几只大牛股。样本量太小统计上没有意义。最大回撤发生在回测初期之后策略在最近几年表现很好但回测刚开始的头两年连续亏损50%。这说明策略可能并没有稳定盈利逻辑或者市场环境已经变了。这些问题都不是指标计算能解决的而是策略开发层面的问题。我自己的习惯是核心策略参数会做一次网格搜索然后把参数热力图打印出来看稳定性。如果热力图是一整块连续的高收益区域说明参数不敏感、策略逻辑比较稳如果只是一个孤零零的尖峰那基本可以断定是过拟合这种策略我会直接放弃。5. 常见问题与坑我把Debug经历讲给你听框架写好了、策略也跑了接下来才是真正的硬仗。我在开发和使用这套框架的过程中遇到了不少经典的“回测魔鬼”每一个都能让结果偏离真实一大截。把这部分整理成一个问题速查表对应到具体场景方便大家排查。问题现象根本原因解决方案回测收益极高但实盘策略完全失效前视偏差信号用了未来数据严格按“信号bar收盘确认-下一bar开盘成交”的时序回测表现不错但回撤数据过小未设置手续费和滑点在Portfolio中加入commission_rate和slippage同一份数据不同日期回测结果差异巨大存在幸存者偏差数据需要包含当前已退市的标的做“点时间”数据集策略总在涨停板买入未处理涨跌停限制在Broker中加入涨跌停过滤逻辑除权除息后价格跳空策略被错误触发使用了未复权数据使用后复权或前复权数据并在数据层标记复权因子策略看起来盈利但持仓数量为负数未限制卖空在Portfolio中加入持仓下限校验下面挑几个最容易踩的坑展开讲讲。5.1 前视偏差最容易犯的错前视偏差是回测里最大的隐形杀手也是最难自查的问题。它指的是策略在回测过程中不知不觉用到了“当时还不知道”的信息。典型的例子是用pandas的shift(-1)把未来数据搬到当前行参与计算。比如你算“明天是否上涨”然后拿这个未来信息来作为今天的买入信号——逻辑是通的但回测结果是完全骗人的。另一个更隐蔽的例子是使用未来数据进行指标计算。比如你在计算当前bar的均线时如果不小心把未来3根bar的收盘价也加进了平均那就会出现轻微的“领先市场”的情况。这种偏差平时看不出来但日积月累回测收益会平白无故地上涨几个百分点。我的排查方法是在框架内部对策略可以访问到的数据范围做严格限制让策略只能看到“截至当前bar收盘”的数据。如果你的数据是全部加载到内存里的DataFrame那策略理论上有能力访问未来的行你必须靠纪律来控制。5.2 复权问题不复权和前复权差异巨大A股股票经常分红、送股、配股这些事件导致股价在除权除息日出现一个向下跳空的价格缺口。如果你的回测直接用不复权价格策略会被这些跳空缺口误导发出大量错误信号。举个例子一只10元的股票10送10后除权价格变成5元。如果不复权图表上看起来直接从10元跌到5元策略可能会误判为暴跌而清仓。但实际上是股本扩大了股东总资产并没有变化。用后复权或前复权数据就能消除这种假象。前复权是以当前价格为基准调整历史价格方便做技术指标看图后复权是以发行价为基准往前推适合计算真实收益率。回测时我一般用后复权因为它的数值不会因为后来新股上市而改变方便后期做收益率归因。用前复权的话每次有新数据加入历史价格都会被重新计算一遍这样回测结果和指标都会不稳定。5.3 滑点与手续费不做这两项回测全是假象我在最初写回测框架时为了节省代码量把手续费和滑点全部设成了0。结果就是策略回测年化50%一上实盘直接变成亏损。原因很简单手续费吃掉了一部分利润滑点又吃掉了一部分。你每笔交易的成本在市场流动性有限的情况下远比你想象的高。以A股为例一次双边交易的成本大致包括佣金万三左右、印花税卖出时千一、过户费十万分之一合计接近千分之一点五。再加上滑点按万五到千一算一进一出就是百分之零点三到百分之零点五。如果策略每个月换手10次一年下来光是交易成本就可能吃掉20%到50%的收益率。回测框架里没有交易成本你的策略就不是在交易是在表演。所以务必在框架里预留好交易成本的位置。我的Portfolio类里commission_rate和slippage这两个参数我会根据回测标的不同设置合理的默认值。A股日线级别我通常设置commission_rate0.0003万三、slippage0.0005万五。如果是加密货币、外汇这种高波动市场滑点参数我会调得更大甚至追加一个基于波动率的动态滑点模型。回归到整个框架上这套自定义回测框架用下来最让我满意的地方不是某个单点功能有多强大而是整个回测过程从数据到指标、从策略到撮合每一步都心里有数。框架是工具工具背后是思路思路清楚工具才好用。对于打算认真做量化的人来说花点时间打磨一套自己的回测框架远比你直接拿一个黑箱框架跑结果、然后对着数字瞎猜要靠谱得多。后面我打算在这个基础上进一步扩展多标的组合回测、分钟级数据支持和实盘接口对接到时候再和大家继续分享。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价