1. 为什么我坚持自己写回测脚本,而不是直接依赖现成框架
1.1 回测到底解决了什么问题
前阵子有位做交易的朋友看了我的策略曲线,问我是不是已经拿真金白银跑起来了。我说还没有,它只是在过去几千个交易日里跑过一遍,年化收益和回撤数据都挺好看,但这不等于实盘也能这么顺。他没继续追问,但我明显感觉到,很多刚接触量化的人其实对“回测”这件事有误解。
回测的本质,是在真实资金投入之前,让一套策略规则在历史数据上完整走一遍。它解决的核心问题是:你的交易思路到底靠不靠谱。一个想法听起来再合理,放到过去五年的数据里跑一遍,可能很快就原形毕露——比如震荡行情里反复止损、趋势来临时又过早离场。与其拿真金白银去验证这些,不如先在历史数据里低成本地犯错。
这就像飞行员在模拟器里练起降,不会一上来就真飞。回测脚本就是你的模拟器。它需要回答的问题很具体:这个策略过去能赚钱吗?单笔盈亏比怎么样?回撤大不大?换手率会不会高到被手续费吃掉利润?只有把这些量化清楚,才谈得上去实盘验证。
我做的这套脚本内部代号叫“披索”,因为最开始我盯着一款新兴市场货币的汇率数据做实验,顺手就用了这个代号。整个项目从零开始搭,包括数据管线、策略信号、撮合引擎、绩效评估四个模块。本文就是这套脚本从无到有的完整开发记录,适合两类人看:一是想入门量化交易但不知道怎么搭建回测体系的初学者,二是已经用过现成工具、觉得不够灵活、想自研一套简单回测框架的开发者。
1.2 现成框架的痛点
在决定自研之前,我并不是没有尝试过市面上的通用回测框架。说实话,几款主流的开源框架各有特色,有的文档齐全、社区活跃,有的性能出色、支持向量化计算。但实际用下来,我遇到几个绕不开的问题。
第一个是学习成本。通用框架为了适配几乎所有策略类型,抽象层级做得非常高。你不仅要学它的API,还要理解它的数据格式、事件流、订单类型、指标计算方式。等我把文档啃完,发现写一个简单的双均线策略都需要套一堆类和方法,完全不像写策略本身,更像伺候一个庞然大物。
第二个是“黑盒”问题。框架内部的撮合逻辑、滑点模型、成本计算方式,默认值往往和我们实际场景对不上。比如有的框架默认按收盘价撮合,这在股票上会带来严重的前视偏差;有的框架手续费按固定比例算,但期货是按手数收的,规则完全不同。框架越通用,默认假设就越多,而每个默认假设都可能是回测和实盘之间的隐形鸿沟。
第三个问题更加实际——定制成本太高。我想在回测里加入最小持仓时间限制、涨跌停无法成交的过滤、以及跨多个交易品种的保证金占用计算,用现成框架实现这些往往要深入重构框架核心逻辑。改到一半你会发现,与其在别人的骨架上动手术,不如从头写自己的。
1.3 自研回测脚本的优势
自己写回测脚本,最大的好处首先是“每一个数字都是可控的”。从数据校验到撮合价格,从手续费率到滑点模型,每一个环节都是我自己的代码,出了问题我能一步步排查到底,而不是对着某个框架的报错信息猜来猜去。
其次是完全按需求定制。我的策略逻辑、数据源、统计口径都由自己决定,想加什么功能就加什么功能。比如我在做“披索”的时候,需要自定义一个结合RSI过滤和趋势跟踪的信号模块,用自研框架只需半天就能搞定,而用通用框架可能要先翻好几篇文档。
技术选型上,我选了Python加pandas、numpy这套组合。没有用C++或者Java,因为回测的核心瓶颈不在计算速度,而在于策略逻辑的灵活迭代。Python生态里数据处理工具非常成熟,写起来快,调试也方便。数据量上,日线几年的数据量撑死几万条,numpy处理起来毫秒级,根本不需要复杂的并发优化。这个选择也大大降低了项目门槛,只要会一点Python基础就能看懂大部分代码。
2. 数据层的那些坑:清洗、复权与时区对齐
2.1 数据源选择
很多人在写回测时最容易忽略的就是数据层,总觉得“有K线数据不就能跑了”。实际上,回测分析结果是否可信,首先取决于底层数据的质量。数据源的稳定性、字段完整性、历史复权处理方式,每一样都能直接改变最终的绩效数字。
我最初用的是免费接口,优点是零成本,但缺点也很明显:连接不稳定,历史数据经常断档,而且部分旧段数据存在明显错误。后来我放弃了免费接口,转用某个付费数据服务商的基础数据包。这不是说免费一定不行,而是回测对历史数据的质量要求很高,如果数据本身有误,再多复杂的策略逻辑也是白搭。
获取到原始数据后,我做了几层基础清洗。第一层是缺失值处理,看看是否有某些交易日的K线缺失,缺了的用相邻交易日数据或前置填充方式补齐,并在日志里标记出来。第二层是异常值检测,比如某根K线突然出现涨跌幅超过50%的情况,大概率是数据错误而非真实行情,需要单独核对。
2.2 除权除息与复权处理
数据清洗里最容易出问题的一环,是股票的除权除息处理。一只股票如果分红或送股,股价会在除权日发生跳空下跌,但这个下跌并不是市场行为,只是数字调整。如果在回测里直接拿原始价格算收益率,除权当天就会莫名其妙出现一根大阴线,策略可能因此触发止损。
解决方法是使用复权价格。常见的有前复权和后复权两种:前复权以当前价格为基准,调整历史价格;后复权以历史价格为基准,调整当前价格。做回测判断时,我推荐使用后复权数据,因为后复权的价格序列不会随着新数据的到来而变化,整个回测过程更为稳定、可复现。我当时在最初几版脚本里用了未复权数据,结果策略在某个除权日的收益被严重虚减,差点误判整个策略失效。
2.3 时区与交易日历
这个坑是很多新手完全没概念的部分。我在做“披索”项目时,数据源服务的市场交易时区和本地并不一致,如果直接把数据框里的时间戳当作本地时间使用,日线收盘价对应的时间点就会错位,导致信号产生前后相差一个交易日,这在回测里相当于作弊或者反过来变成延迟成交。
正确的做法是:让所有K线数据使用交易所所在地的时区,并且使用对应交易所的官方交易日历。对不同市场来说,节假日休市日期也不一样,比如某个市场在特定节假日休市,而另一个市场照常交易。如果直接用系统默认日历去切分数据,很容易把非交易日当成交易日,进而导致收益率计算错位。
在代码里,我单独维护了一个交易日表,每天数据拉取后先校验日期是否在交易日历内,再检查该日期数据是否完整。这一步让后续所有策略逻辑都建立在一个干净一致的时间轴上。
2.4 数据验证的三条硬规则
清洗完之后,我给自己定了几条硬规则,每次拿到新数据都要过一遍。
第一,区间完整性规则。以日线数据为例,每个交易日的开盘价、最高价、最低价、收盘价必须齐全,且满足最高价不低于最低价、开盘价和收盘价都在最低最高范围之内。不满足的直接标红。
第二,收益率合理性规则。正常市场里,单日收益率波动超过5%通常需要警惕(极端行情除外),连续多天出现异常波动时,必须回源头确认是不是拆股、送股或数据录入错误。
第三,前后对齐规则。如果一条数据的前收盘价和后一天的开盘价出现巨大跳空,但并没有对应的除权除息公告,说明数据源可能出现了脏数据,需要处理。
这些规则让我避免了不少后续的麻烦。很多时候策略回测结果异常,追根溯源会发现根本不是策略逻辑问题,而是数据在某个环节已经出了问题。
3. 策略信号模块:从“双均线+RSI过滤”到可执行的持仓目标
3.1 策略选择与逻辑
回测框架搭起来之后,就得有一个能跑的策略作为研究对象。我没有一开始就追求复杂策略,而是选了一个经典且逻辑清晰的组合:双均线加RSI过滤。
为什么选这个?双均线是趋势跟踪策略里最基础的一种,逻辑简单、容易解释、代码实现难度低。它的核心思想是:短期均线向上穿越长期均线时,说明短期趋势强于长期趋势,做多;短期均线向下穿越长期均线时,说明趋势转弱,平仓离场。这个规则虽然简单,但它是理解整个回测流程如何运作的一个很好的载体。
RSI(相对强弱指数)在这里的作用是过滤。双均线策略在震荡行情里容易被来回打脸,频繁产生假突破。我用一个RSI阈值条件,要求只有当RSI没有进入超买区域时才允许开多仓,从而减少一部分无效交易。
策略的完整逻辑如下:当快线(比如5日均线)上穿慢线(比如20日均线),且RSI小于70时,开多仓;当快线下穿慢线,或RSI大于85时,平仓。这个策略并不追求高胜率,而是追求“截断亏损、让利润奔跑”的趋势跟踪效果。
3.2 从信号到目标持仓
策略逻辑相对容易写,但要把信号变成回测引擎能够识别的持仓目标,还需要多加一层处理。我一开始的做法是直接根据信号出现就立即改变持仓状态,结果回测结果剧烈波动,后来发现原因是信号本身在连续几天反复触发,导致持仓状态频繁切换。
解决的思路是引入“目标持仓”的概念。策略输出的不是每一次触发信号时的临时判断,而是一个在每个交易日结束时确定的持仓目标:持多仓、空仓还是其他状态。引擎在下一个交易日再根据这个目标来执行买入或卖出。
具体来说,代码里我用一个shift操作,把T日收盘时计算出的持仓目标顺延到T+1日执行。这样可以有效避免“当天计算、当天以收盘价成交”的前视偏差。代码实现大概像下面这样简单。
import pandas as pd def generate_market_timing_signals(df, fast=5, slow=20, rsi_buy=70, rsi_sell=85): df = df.copy() df['fast_ma'] = df['close'].rolling(fast).mean() df['slow_ma'] = df['close'].rolling(slow).mean() # RSI简版计算,用于说明信号逻辑 delta = df['close'].diff() gain = delta.clip(lower=0).rolling(14).mean() loss = (-delta.clip(upper=0)).rolling(14).mean() rs = gain / (loss + 1e-9) df['rsi'] = 100 - (100 / (1 + rs)) # 目标持仓:1代表持有多头,0代表空仓 df['target'] = 0 df.loc[(df['fast_ma'] > df['slow_ma']) & (df['rsi'] < rsi_buy), 'target'] = 1 df.loc[(df['fast_ma'] < df['slow_ma']) & (df['rsi'] > rsi_sell), 'target'] = 0 # 重要:只保留状态变化的日子,避免频繁换仓 df['target'] = df['target'].replace(to_replace={0: 0, 1: 1}) return df这里最大的关键点是:信号必须在当日收盘之后产生,而成交在下一个交易日。如果直接用当日收盘价格去成交,就是典型的“偷看未来”,这在第6章会专门展开讲。
3.3 参数与敏感性分析
双均线策略有两个关键参数:快线周期和慢线周期。不同市场、不同品种对参数的敏感度差异很大。我在“披索”项目里对快线取了5、10、15三个值,慢线取了20、30、60三个值,组合成9组参数分别回测,然后把年化收益和最大回撤做成一个小表格对比。
结果很有意思:并非参数越短越好,也并非越长越好。短均线组合在趋势行情里反应灵敏,但震荡市里止损频繁;长均线组合胜率更高,但交易次数少,一旦错过启动点就整段踏空。参数选择必须在具体数据上做验证,而不是拍脑袋决定。
还有一个常被忽视的问题:参数敏感性。一个稳健的策略,其绩效在参数附近一定范围内变化时,结果不会有剧烈波动。如果快线参数从5改成6,年化收益率就从25%跌到5%,那这个参数大概率是过拟合出来的,实盘时只要市场环境稍有变化就会失效。我建议在选定参数后,一定要把相邻几组参数的结果都跑一遍,看趋势是否平稳。
4. 回测引擎最关键的三个组件:撮合假设、交易成本和仓位管理
4.1 撮合假设:为什么必须用T+1日开盘价成交
回测引擎是整个脚本的灵魂,而撮合假设是引擎里最需要谨慎设计的地方。我在开发过程中碰到过一个经典问题:向量化回测里,信号在T日收盘后产生,如果用T日收盘价作为成交价,就等于是用未来信息去回测过去,结果当然会好得离谱。
解决这个问题,我采用的是延迟一天执行的撮合假设。T日收盘时确认信号,T+1日以开盘价成交。这样做的理由是:在实际交易里,策略信号计算需要时间,下单也可能会在第二天的集合竞价阶段才真正执行。不是最佳价格假设,但胜在贴近真实,结果更有参考价值。
在某些高流动性市场里,也可以假设T日收盘附近的限价单能以接近收盘价成交,但这对日内交易来说过于理想化了。我的原则很简单:宁可让撮合价格比实盘差一些,也不能让回测结果因为过于理想的成交而虚高。
4.2 交易成本:手续费、滑点和冲击成本
交易成本是回测结果和实盘结果差异最大的来源之一。很多人写第一个回测脚本时把手续费设成0,结果策略年年化收益超过50%,一上实盘就变成亏损。原因很简单:高换手率策略的利润全部被手续费和滑点吃掉了。
在我的脚本里,成本模型包含三部分。
第一,固定手续费,按交易金额的比例或按每笔固定金额收取。比如股票交易,佣金按成交额的万分之二点五收取,卖出时还有印花税。第二,滑点,这是最容易被忽略的。滑点指从下单到实际成交之间价格变化的差额。对流动性较好的市场,我通常假设单边滑点为0.1%左右;对流动性差的品种,滑点可能需要提高到0.3%甚至更多。第三,冲击成本,当你的下单量较大时,自己的交易行为本身会影响市场价格。这块在小资金回测里通常可以忽略,但如果回测涉及大资金容量策略,必须加进来。
下面是我在脚本里使用的一套成本计算逻辑,按比例成本模式实现。
def calculate_trade_cost(notional, commission_rate=0.00025, slippage_rate=0.001, stamp_duty_rate=0.0005, is_sell=False): commission = notional * commission_rate slippage = notional * slippage_rate stamp_duty = notional * stamp_duty_rate if is_sell else 0 return commission + slippage + stamp_duty这个函数里,滑点加入的是实际成交价格偏移造成的隐性成本,印花税只在卖出时收取。样本外测试时,我会把费率再乘一个1.2的缓冲系数,给结果留下安全边际。
4.3 仓位与资金管理
策略逻辑决定了何时买卖,仓位管理决定了每笔交易投入多少资金。固定比例仓位是最简单的做法:每次开仓投入总资金的固定比例,比如20%。但这种方法在连续亏损时会很快缩水,因为每次动用本金的比例固定,但本金在变小。
我在“披索”项目里用的是固定风险加仓位的方式。每笔交易前,根据当前账户净值确定风险预算,比如单笔最大允许亏损不超过当前净值的1%,再根据入场价和止损价之间的距离反算出应该买入的数量。这个方法的好处是:当市场波动大时自动减少仓位,波动小时自动放大仓位,能够在控制回撤的前提下提高资金使用效率。
别再神话凯利公式,完整的凯利公式需要非常准确的胜率和盈亏比估计,而这两个数字在真实市场里是动态变化的。我的建议是:用固定风险仓位就够了,回撤控制永远比利润追逐重要。
4.4 事件驱动 vs 向量化:为什么最终选了bar级事件驱动
一开始我的回测引擎是纯向量化的,整个持仓净值序列用pandas的向量运算一步算完,速度很快,代码也短。但向量化回测有一个天然的问题:它很难处理“根据当前持仓状态改变后续决策”的逻辑,也就是组合管理中常见的路径依赖。
举个简单例子:一个策略规定“如果当前已有持仓,则不再开新仓”。这种依赖过去状态的逻辑,在纯向量化里写起来非常别扭,要么用循环套向量,要么想一堆技巧绕过去。所以我后来将引擎重构为按bar循环处理的事件驱动模式。
其实不用把它想复杂了。引擎每次处理一根K线,先看这根K线开盘时是否有待执行的挂单,有就成交并更新持仓;然后读取这一根K线完整的OHLC数据,判断信号条件是否满足;如果触发信号,就把目标持仓更新为下一天的开盘执行单。这种逐根K线更新的方式虽然比纯向量化多写一些代码,但逻辑清晰,几乎能模拟任何实际交易规则。
下面是一个bar级回测循环的简化代码框架,展示了成交和持仓更新的思路。
def run_backtest(df, initial_cash=1000000): cash = initial_cash position = 0 pending_order = 0 # 待成交目标持仓,T+1执行 records = [] for i in range(len(df)): row = df.iloc[i] # 1. 如果存在待成交订单,以当日开盘价成交 if pending_order != position: trade_price = row['open'] * (1 + slippage_rate) if pending_order > position else row['open'] * (1 - slippage_rate) trade_value = trade_price * abs(pending_order - position) # 手续费、滑点、印花税的计算省略示意 position = pending_order # 2. 根据当日行情信号更新下一日的目标持仓 pending_order = int(row['target']) records.append({'date': row.name, 'close': row['close'], 'net_value': cash + position * row['close']}) return pd.DataFrame(records)这种代码的好处是每一步都有迹可循,调试起来极其方便。回测出问题时,我能直接定位到是哪个bar、哪次成交导致净值异常,而不是面对一长串向量化结果发呆。
5. 绩效评估:别只看年化收益率
5.1 核心指标的统计口径
回测跑完之后,最忌斜眼只看最后一行年化收益率。一个策略的好坏必须从多个维度评估,而每个指标都有它自己的价值以及局限性。
下面我用表格列一下自己常用的几个核心指标及其计算口径,这也是我在项目里给每次回测结果打分的依据。
| 指标 | 计算方式 | 观察重点 |
|---|---|---|
| 年化收益率 | 总收益按时间长度折算为年度水平 | 策略的长期盈利能力 |
| 最大回撤 | 净值从峰值跌到后续谷底的最大幅度 | 风险承受能力的底线 |
| 夏普比率 | (策略年化收益 - 无风险利率)/ 年化波动率 | 收益与风险的平衡度 |
| 卡玛比率 | 年化收益率 / 最大回撤 | 收益回撤比,比夏普更直观 |
| 胜率 | 盈利交易次数 / 总交易次数 | 策略的有效性 |
| 盈亏比 | 平均单笔盈利 / 平均单笔亏损 | 单次成功能否覆盖多次失败 |
| 换手率 | 交易额 / 平均持仓市值 | 成本敏感性评估 |
在这些指标里,我最看重的是卡玛比率。一个年化收益率30%、但最大回撤达到40%的策略,和另一个年化收益率15%、最大回撤只有8%的策略,前者的实际体验远远不如后者。很多新手只盯着收益率,忽略了回撤这个最能体现真实持仓压力的数据。
5.2 净值曲线与收益分布
一眼多看净值曲线的形态。一个健康的策略,净值曲线应当是相对平滑地上行,即使有回撤,也应该是短促而可控的。如果一条曲线呈现出“长时间横盘,突然某个月猛涨50%”,那么你需要检查一下这笔利润是不是恰好踩中了一次极端行情,这种策略在实盘里很难复制。
更客观的方法是画出每笔交易的收益分布直方图。如果大部分交易都在亏损,但偶尔一笔大赚把整体收益拽回正数,说明这个策略是典型的“高赔率低胜率”模式。这种模式本身没问题,但它对资金管理提出了极高要求:在连续几十笔小亏之后,你还能不能坚守策略,等待那笔大赚的机会?
我在每次回测后还会做一个简单但有效的假设检验:把所有交易收益做成一个序列,检验其均值是否显著大于0。如果t检验的p值大于0.05,说明当前收益有可能是由少数几笔极端交易驱动,而不是策略存在真正的统计优势。这个测试不能决定策略是否可用,但它能提醒我不要被漂亮的总收益数字冲昏头脑。
5.3 样本外与过拟合检测
这是整个回测开发流程里最容易犯错、也最值得强调的地方。很多人在开发策略时,直接用全量历史数据做参数优化,然后在同样的数据上汇报绩效,这种做法得到的收益曲线天然过分乐观,因为参数已经被数据“训练”过了。
我的做法是把历史行情严格分成训练集和样本外测试集两部分,前部分用于参数筛选,后部分仅用于最终验证。参数筛选阶段只允许在训练集内反复调整,等到模型定稿后再拿到样本外数据上完整跑一遍。如果样本外绩效大幅低于训练集绩效,说明策略已经过拟合,需要重新设计逻辑或者参数范围。
更严格一点,我还会使用滚动窗口样本外测试:把整个历史切成多个连续区间,每次用前一段来定参数,后一段来测试,然后把所有样本外结果拼成一条“纯样本外净值曲线”。这条曲线反映的是策略如果一直坚持同样的参数更新流程,在历史各个时段里真实会发生的表现,是最接近实盘演进的模拟方式。
5.4 自动生成回测报告
当回测参数比较多、策略版本更新频繁的时候,手动记录每个版本的绩效非常容易出错。我给脚本加了一个报告生成模块,每次跑完自动计算所有核心指标,输出净值曲线图、回撤图、月度收益热力图以及交易记录明细,最后汇总到一个网页报告里。
这样,每次改完策略逻辑,都能快速看到它与上一次版本之间的差异。版本对比是策略开发里很容易被忽视的一环:没有基线版本做对照,你很难判断这个改动到底是真正提升了策略质量,还是仅仅过拟合了历史数据里的噪音。
6. 回测脚本开发中最容易踩的七个坑(附避坑清单)
6.1 前视偏差(Look-ahead Bias)
前视偏差是我看到过最多的回测错误,也是最隐蔽的。它指的是在策略计算时,使用了当时“实际上还不可能知道”的未来数据。最典型的场景是:策略每天收盘后计算出信号,但撮合时却用当天收盘价成交。虽然看起来只是小小的时序问题,却会让回测结果严重虚高。
我之前在开发“披索”项目的过程中就踩过一次。当时为了节省一个shift操作,信号计算和成交撮合放在同一根bar里处理,结果一年期回测年化收益瞬间多了好几个百分点。后来我在代码里严格规定:一切信号必须基于T日以及T日之前的数据,一切成交必须发生在T+1日。这条规则我写成了注释放在回测引擎文件的第一行,提醒自己不要再犯。
检查方法很简单:把生成的信号和成交日期打印出来,看是否存在“当日计算当日成交”的情况;或者手动抽查几笔交易,确认成交价格对应的bar晚于信号产生的bar。
6.2 幸存者偏差
幸存者偏差存在于选股策略中,但其思想也适用于所有品类。如果回测时使用的是“今天仍存在于市场上的资产”,那么已经退市、破产的资产会被自动忽略,策略表现自然被高估。这就好比统计战争归来飞机的弹孔,却忘了那些被打中引擎没回来的飞机。
解决这个问题的标准做法有两种:一是使用全量历史数据,包括那些后来退市的品种;二是使用历史时点成分股列表,逐期还原当时可交易的品种范围。如果数据源不支持这两点,至少要清楚自己的回测存在幸存者偏差,并对高收益结果保持足够怀疑。
6.3 复权处理不当
这个坑在第2章已经提到过。除权除息时,原始价格会出现非市场因素的跳空,如果不进行后复权处理,回测中会产生不存在的巨大跌幅,策略极容易被误导性止损。
另外提一个进阶细节:用后复权数据计算信号时,因为历史价格会被统一压缩到一个量级,所以均线类指标的数值大小和真实行情有差异,但信号方向、相对位置关系仍然是一致的。只要不把复权价格直接当作实际成交价即可,这一点在回测文档里可以留个说明。
6.4 参数过拟合
参数过拟合是回测开发中最容易出现、又最难以彻底解决的问题。它的本质是:策略参数过度适应了历史行情中的随机噪音,而不是捕捉到了真实的规律信号。
我发现过拟合的一个有效手段,是观察参数热力图。把两个主要参数作为坐标轴,把年化收益作为颜色值画出热力图,过拟合的参数区域表现为“孤岛式”的高收益点,而稳健策略的参数区域则表现为“高原式”的高收益区间。孤岛附近的参数通常不可靠,因为稍微偏离一点,绩效就会断崖式下降。
6.5 成本设置过低或为零
我已经反复强调交易成本的重要性,但这里还是值得单独列出来。很多新手在回测时把手续费设置为0,仅仅是因为“先看策略逻辑正不正确”。但正是这种“先不看成本”的习惯,让许多策略在加入真实成本后直接崩溃。
我的建议是:从一开始就按真实市场标准设置滑点和手续费,甚至在上线前刻意调高20%的成本来做压力测试。如果一个策略在成本增加20%时绩效变化不大,说明它有充足的安全边际;如果一个策略在成本增加后由盈转亏,那它本质上只是“成本套利”,迟早会在实盘里被市场教育。
6.6 数据时间戳错误
时间戳错误这个坑比前视偏差更隐蔽,因为回测结果未必会表现出异常,只会潜移默化地偏移。比如某些数据源返回的时区字段和文档不符,导致K线的有效时间整体偏移几个小时,策略在T日生成的信号,在数据上看起来却在T+1日的盘中产生,最终成交价格明显失真。
我的检查方法是:随便选一个工作日,把该日K线的时间、价格与交易所官网披露的行情对照一下,确保对应关系一致。另外,同一个项目中所有数据必须统一时区规范,要么全部用UTC存储、展示时再转换,要么全部用交易所本地时间,千万不要混用。
6.7 忽略极端行情与流动性限制
最后这个坑,关乎回测结果的真实可交易性。很多策略在回测里表现良好,但在极端行情下,比如涨跌停、临时停牌、市场熔断的时候,你根本没法按回测设定的价格成交。止损单可能直接以更差价格成交,甚至完全无法成交。
我在回测引擎里加入了两个过滤逻辑:一是当日涨跌停时标记为无法成交,并顺延到下一交易日;二是当日成交量过小时,限制撮合的成交数量。这些限制会让回测结果滑落一些,但换来了对实盘更好的模拟。做压力测试时,我还会主动剔除历史上波动最大的几天,看策略在最恶劣的环境里能否扛住。
6.8 避坑速查表
| 问题 | 典型表现 | 检查与修复方式 |
|---|---|---|
| 前视偏差 | 回测收益高得离谱,实盘差异巨大 | 用下一日开盘价成交,信号与执行严格隔日 |
| 幸存者偏差 | 历史回测包含大量已退市品种 | 使用全量数据或历史时点成分股 |
| 复权处理不当 | 除权日出现假跳空 | 统一使用后复权价格计算指标 |
| 参数过拟合 | 参数微调后绩效断崖式下降 | 绘制参数热力图,做样本外验证 |
| 成本设置为0 | 高换手策略虚高收益 | 手动加入手续费和至少0.1%滑点 |
| 时间戳错误 | 信号与K线时间错位 | 核对交易日历和时区,统一时间格式 |
| 忽略极端行情 | 极端行情下止损无法成交 | 加入涨停跌停不成交、低流动性限额逻辑 |
写在最后的一点实际体会
回测脚本开发到能稳定运行并不难,难的是让它可靠地接近真实市场。我在做“披索”项目的过程中,最深的体会是:每一处让人省心的默认假设,最终都会变成回测与实盘之间的一笔隐性成本。撮合价格、手续费率、数据时间戳、参数优化方式,每一项上都多花一些心思,回测结果才会多一分参考价值。
如果非要说一个最重要的建议,那就是做任何回测之前,先复制出一份样本外数据锁起来,只有等策略全部定型之后才打开它。这个习惯救过我很多次,也建议所有刚开始搭回测流程的人尽早养成。