三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

量化回测实战指南:从双均线策略到避免未来函数陷阱

量化回测实战指南:从双均线策略到避免未来函数陷阱

1. 量化回测:策略的“时光机”与“压力测试”

刚入行的朋友,或者自己摸索量化交易有一阵子的朋友,经常会遇到一个场景:脑子里蹦出一个绝妙的交易想法,比如“当股价突破20日均线时买入,跌破10日均线时卖出”,感觉这个策略稳赚不赔。但真金白银投进去之前,心里总得打个鼓:这个策略在过去几年里到底行不行?能赚多少钱?最大会亏多少?会不会在某次股灾里直接爆仓?

这时候,你就需要“量化回测”这个工具了。你可以把它想象成一台金融领域的“时光机”和“压力测试仪”的结合体。它的核心任务,就是用历史数据,按照你设定好的交易规则,模拟一遍过去市场的交易过程,然后给你一份详尽的“模拟成绩单”。这份成绩单会告诉你,如果你的策略在过去被严格执行,会获得多少收益,承担多大风险,以及最重要的——它是否真的有效,还是仅仅靠运气蒙对了几次。

为什么回测如此重要?因为金融市场充满了“幸存者偏差”和“事后诸葛亮”的陷阱。一个策略听起来再完美,不经历史数据的检验,都可能是纸上谈兵。回测能帮你把感性的想法,变成可量化的、可验证的规则。无论是简单的均线交叉,还是复杂的机器学习模型,都需要经过回测这关,才能从“想法”晋级为值得进一步研究的“候选策略”。

那么,谁需要懂回测呢?我认为,只要你涉及策略开发,无论是全职的量化研究员,还是业余的个人交易者,甚至是金融专业的学生,回测都是必须掌握的基本功。它不仅是检验策略的基石,更是理解市场、完善交易系统思维的最佳实践途径。

2. 回测的核心思想与常见误区

2.1 “未来函数”与回测的“上帝视角”陷阱

在开始动手之前,我们必须先理解回测中最核心、也最容易犯错的一个概念:未来函数。所谓未来函数,就是指在回测的某个时间点,策略使用了当时还无法获得的信息。

举个例子:你的策略是“如果今日收盘价高于昨日收盘价,则明日开盘买入”。在回测到2023年5月10日时,程序知道这一天的收盘价是100元,也知道5月9日的收盘价是98元(因为历史数据已经全部加载)。于是它判断条件成立,并决定在5月11日的开盘价进行买入。这里的关键在于,在5月10日收盘的那个真实时刻,你是不知道5月11日的开盘价是多少的。你的交易信号基于10日的数据,但成交价却用了11日的数据,这本质上是一种“偷看”。

更严重的未来函数是直接用到了未来的数据做决策。比如:“如果明日股价上涨,则今日买入”。这在实际交易中是完全不可能实现的。但在编写回测代码时,如果数据索引错位,就很容易犯这种低级而致命的错误。

注意:避免未来函数是回测可信度的生命线。一个包含未来函数的策略,其回测结果会异常漂亮,但实盘时必然失效。检查的方法很简单:在回测引擎运行的每一个时点,问自己“在这一刻,我能知道哪些信息?” 你只能使用这一刻及之前的历史数据。

2.2 回测不等于实盘:理想与现实的差距

很多新手会沉迷于回测曲线那优美的45度向上增长,以为找到了“圣杯”。但我们必须清醒认识到,回测是对历史的一种理想化模拟,它与实盘交易存在巨大差距:

  1. 交易成本:回测中常常忽略或低估佣金、印花税、滑点(实际成交价与预期价的偏差)。对于高频或短线策略,这些成本足以吞噬所有利润。
  2. 流动性假设:回测默认你的订单能立即以当前价格全部成交。但在实盘中,大额订单可能会影响市场价格(冲击成本),在流动性差的股票或极端行情下,你可能根本无法以预设价格成交。
  3. 数据质量:历史数据可能存在错误,如价格异常、复权错误(除权除息处理不当)、幸存者偏差(回测用的股票列表只包含了至今还存在的公司,忽略了那些已经退市的“失败者”)。
  4. 市场环境变化:过去有效的模式,未来可能失效。因为市场的参与者、规则、宏观经济结构都在变化。用2015年牛市数据训练的策略,在2023年的震荡市中可能完全无效。

理解这些差距,不是为了否定回测,而是为了让我们更客观地看待回测结果。回测的目标不是追求一个“完美”的曲线,而是评估策略的逻辑一致性统计显著性,并为实盘交易提供风险参考。

3. 搭建个人回测系统的具体步骤

下面,我将以一个经典的“双均线交叉策略”为例,详细拆解从零搭建一个回测系统的完整步骤。我会使用Python和backtrader这个流行的回测框架来演示,但思路适用于任何工具。

3.1 第一步:明确策略逻辑与目标

在写任何代码之前,先用自然语言把你的策略规则写清楚。对于我们的“双均线策略”:

  • 标的:沪深300指数(这里用ETF代替,如510300.SH)。
  • 数据:日线行情(开盘价、最高价、最低价、收盘价、成交量)。
  • 信号生成
    • 计算两个移动平均线:短期均线(如SMA20,20日简单移动平均)、长期均线(如SMA60,60日简单移动平均)。
    • 买入信号:当短期均线从下方上穿长期均线时(金叉),在下一个交易日开盘全仓买入。
    • 卖出信号:当短期均线从上方下穿长期均线时(死叉),在下一个交易日开盘全仓卖出。
  • 初始资金:100,000元。
  • 回测周期:2018年1月1日至2023年12月31日。
  • 评估目标:我们不仅看总收益率,更要关注年化收益率、最大回撤、夏普比率等风险收益指标。

3.2 第二步:准备与处理历史数据

数据是回测的粮食,垃圾数据进,垃圾结果出。

  1. 数据来源:个人开发者可以从一些免费或低成本渠道获取,如aksharetushare(需积分)、yfinance(美股)。这里我们用akshare获取。
  2. 数据清洗
    • 处理缺失值:对于停牌等原因导致的缺失交易日,需要向前或向后填充,或直接删除该行。
    • 复权处理:这是重中之重!股票会有分红送股,导致价格出现跳空。我们必须使用后复权价格进行回测,这样才能真实反映股价的连续增长。akshare获取的数据通常包含复权因子,需要自己计算复权价,或者直接获取已复权的数据。
    • 格式标准化:回测框架通常需要DataFrame,且索引为日期时间格式,列名包括open,high,low,close,volume
import akshare as ak import pandas as pd # 获取510300(沪深300ETF)的后复权数据 df = ak.stock_zh_a_hist(symbol="510300", period="daily", start_date="20180101", end_date="20231231", adjust="hfq") # 调整列名和格式以适配backtrader df['日期'] = pd.to_datetime(df['日期']) df.set_index('日期', inplace=True) df = df[['开盘', '最高', '最低', '收盘', '成交量']] df.columns = ['open', 'high', 'low', 'close', 'volume']

3.3 第三步:选择与初始化回测框架

对于Python,backtraderzipline是两大主流选择。backtrader更灵活,易于自定义;zipline(及其在线版Quantopian)更标准化,但已停止维护。我们选backtrader

import backtrader as bt # 创建大脑引擎 cerebro = bt.Cerebro() # 设置初始资金 cerebro.broker.setcash(100000.0) # 设置交易手续费,这里设为万分之三,单边最低5元 cerebro.broker.setcommission(commission=0.0003, stocklike=True)

3.4 第四步:实现策略逻辑类

这是核心步骤,我们需要创建一个继承自bt.Strategy的类。

class DualMovingAverageStrategy(bt.Strategy): # 定义策略参数 params = ( ('short_period', 20), ('long_period', 60), ) def __init__(self): # 保存对数据线(close)的引用 self.dataclose = self.datas[0].close # 初始化指标:计算两条移动平均线 self.sma_short = bt.indicators.SimpleMovingAverage( self.datas[0], period=self.params.short_period ) self.sma_long = bt.indicators.SimpleMovingAverage( self.datas[0], period=self.params.long_period ) # 跟踪订单状态和买卖信号 self.order = None self.buy_signal = bt.indicators.CrossOver(self.sma_short, self.sma_long) def next(self): # 每个bar(这里是一天)都会执行这个函数 # 检查是否有未完成的订单,有则跳过 if self.order: return # 检查是否持有仓位 if not self.position: # 如果没有持仓,且出现金叉(buy_signal > 0),则买入 if self.buy_signal[0] > 0: self.order = self.buy() # 市价单,默认下一个bar开盘价成交 else: # 如果持有仓位,且出现死叉(buy_signal < 0),则卖出 if self.buy_signal[0] < 0: self.order = self.sell()

这里有几个关键点:

  • __init__:用于定义指标和初始化变量。指标计算在这里完成,next函数中直接使用结果,避免重复计算。
  • next:策略的逻辑核心。它会在每个时间点(如每天)被调用。self.position可以查看当前持仓。
  • self.buy() / self.sell():发出市价单指令。在backtrader中,默认会在下一个bar的开盘价成交,这模拟了“今日收盘后生成信号,明日开盘执行”的真实场景,有效避免了“收盘价偷看”的未来函数。

3.5 第五步:组装并运行回测引擎

将数据、策略、分析器都加载到大脑中,并运行。

# 将数据加载到引擎 data = bt.feeds.PandasData(dataname=df) cerebro.adddata(data) # 将策略添加到引擎 cerebro.addstrategy(DualMovingAverageStrategy) # 添加分析器:夏普比率、年化收益、最大回撤等 cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe') cerebro.addanalyzer(bt.analyzers.AnnualReturn, _name='annual') cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown') # 设置回测结果打印精度 cerebro.addobserver(bt.observers.Value) # 运行回测 print('初始资金: %.2f' % cerebro.broker.getvalue()) results = cerebro.run() print('最终资金: %.2f' % cerebro.broker.getvalue())

3.6 第六步:分析回测结果与可视化

运行后,我们需要从results中提取分析器数据,并绘制图表。

# 获取第一个策略实例(因为我们只加了一个策略) strategy = results[0] # 打印关键绩效指标 print('夏普比率:', strategy.analyzers.sharpe.get_analysis()) print('年化收益率:', strategy.analyzers.annual.get_analysis()) dd_analysis = strategy.analyzers.drawdown.get_analysis() print('最大回撤: %.2f%%' % dd_analysis['max']['drawdown']) print('最大回撤周期: %d' % dd_analysis['max']['len']) # 绘制回测图表 cerebro.plot(style='candlestick')

图表会展示价格曲线、均线、买卖点标记、资产曲线和回撤曲线,一目了然。

4. 深入解析回测的关键细节与参数

4.1 成交价模拟:市价单、限价单与滑点

在基础回测中,我们用了最简单的self.buy(),这是市价单。但实盘中,我们可能用限价单。backtrader允许你自定义订单类型和成交逻辑。

更现实的是加入滑点。滑点是指订单预期成交价与实际成交价之间的差额。在高波动或流动性不足时,滑点可能很大。

# 在初始化cerebro后,可以设置滑点模型 cerebro.broker.set_slippage_fixed(0.01) # 固定滑点,每笔交易滑点0.01元 # 或百分比滑点 cerebro.broker.set_slippage_perc(0.001) # 0.1%的百分比滑点

加入滑点后,你的回测收益通常会下降,但这更接近真实情况。

4.2 交易成本模型:佣金、印花税与冲击成本

除了滑点,完整的成本模型还包括:

  • 佣金:券商收取,通常按成交金额的万分之几计算,有最低消费(如5元)。我们在setcommission中已经设置。
  • 印花税:卖出时收取,目前A股是成交金额的千分之一。backtradersetcommission默认是双边收取,我们需要自定义一个佣金类来模拟单边印花税。
  • 冲击成本:大额订单推动市场价格朝不利方向变动带来的成本。模拟这个比较复杂,通常需要根据订单大小和市场的订单簿深度来估算,对于初级回测可以暂不考虑。

4.3 仓位管理与资金管理

我们的例子是全仓进出,这是最简单的仓位管理。更高级的策略会涉及:

  • 固定分数仓位:每次投入固定比例的资金(如总资产的10%)。
  • 凯利公式:基于胜率和赔率计算最优仓位。
  • 金字塔加仓/减仓:在盈利或亏损时,分批调整仓位。

backtradernext函数中,你可以通过self.buy(size=...)来指定买入的股数或比例,从而实现复杂的资金管理逻辑。

5. 回测结果评估与过拟合防范

拿到一份回测报告,怎么看?不能只看总收益。

5.1 核心绩效指标解读

  1. 总收益率:最直观,但参考价值有限。
  2. 年化收益率:将总收益折算到每年,便于比较不同时间长度的策略。(最终价值/初始价值)^(1/年数) - 1
  3. 最大回撤:策略从任一峰值跌到随后谷底的最大跌幅。这是衡量策略风险最重要的指标之一。一个年化收益30%但最大回撤50%的策略,很可能在实盘中途因为无法承受巨额浮亏而被洗出场。
  4. 夏普比率:衡量每承担一单位总风险,所获得的超额回报。公式为(策略年化收益 - 无风险利率) / 策略年化波动率。通常大于1算不错,大于2就算优秀了。它同时考虑了收益和风险。
  5. 胜率与盈亏比:胜率 = 盈利交易次数 / 总交易次数。盈亏比 = 平均盈利金额 / 平均亏损金额。一个高胜率但盈亏比低的策略,可能一次大亏就抹平所有盈利。

5.2 过拟合:回测中最危险的“敌人”

过拟合是指你的策略过于完美地“贴合”了历史数据,以至于捕捉到了很多历史噪音而非普遍规律,导致在未来(实盘)表现很差。

如何识别和防范过拟合?

  1. 样本外测试:这是黄金法则。将历史数据分为两部分:训练集(用于开发优化策略)和测试集(或样本外集,用于最终验证)。绝对不能用测试集的数据做任何参数优化。如果策略在训练集上表现很好,在测试集上表现很差,那就是典型的过拟合。
  2. 简化策略逻辑:策略规则越复杂、参数越多,过拟合的风险就越高。奥卡姆剃刀原则:如无必要,勿增实体。一个只有1-2个核心逻辑的策略,往往比拥有10个过滤条件的策略更稳健。
  3. 避免过度优化:不要为了追求回测曲线的完美,而疯狂调整参数。例如,你把均线周期从(20,60)微调到(23,58),可能回测收益提高了2%,但这很可能是运气。应该测试参数在一个合理范围内的表现是否稳定。
  4. 进行敏感性分析:轻微改变参数(如均线周期±5),观察策略绩效是否发生剧烈下滑。稳健的策略应对参数小范围变动不敏感。
  5. 多市场、多周期验证:如果一个策略在沪深300、中证500、美股标普500等不同市场,在日线、周线等不同周期上都能表现出正期望,那么它的可靠性就高得多。

6. 从回测到实盘的桥梁:模拟交易

在投入实盘真金白银之前,强烈建议进行一段时间的模拟交易(Paper Trading)。模拟交易使用实时或延迟的市场数据,但用虚拟资金执行你的策略。

模拟交易的价值:

  • 检验系统健壮性:检查你的实盘数据接口、交易指令发送、风险控制模块是否正常工作,会不会出现程序Bug。
  • 感受市场节奏:体验真实订单的成交情况、滑点、以及策略在实时市场中的心理感受。
  • 最终验证:这是策略投入实盘前的最后一道,也是最重要的一道检验。如果模拟交易结果与回测结果差异巨大,就需要回头检查回测假设是否存在问题。

你可以使用券商提供的模拟交易API,或者用backtrader结合实时数据源(难度较高)来搭建自己的模拟交易系统。一个更务实的方法是,用历史回测证明策略逻辑,然后用小资金(比如总资金的1-5%)进行实盘试单,在真实环境中观察策略的运行,同时严格控制风险。

量化回测是一个严谨而有趣的过程,它融合了金融知识、编程技能和统计思维。记住,没有一个策略能通过回测证明它在未来一定赚钱,但一个好的回测过程能帮你排除掉那些很可能在未来亏钱的策略。保持怀疑,持续迭代,管理好风险,这才是量化交易长期生存下去的关键。我个人最深的体会是,把80%的精力花在防止策略过拟合和构建稳健的风险管理规则上,比花在寻找“圣杯”信号上要有价值得多。当你看到一个回测结果时,多问几个“为什么这么好?”和“在什么情况下会失效?”,你的策略开发之路就会稳健很多。

← 返回列表