ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Backtrader实现ETF期权备兑策略:回测框架选型与关键细节

Backtrader实现ETF期权备兑策略:回测框架选型与关键细节 干量化这几年回测框架往往比策略本身更影响开发效率。我最早做备兑策略时用自建的事件循环后来整体迁移到Backtrader把上证50ETF期权、沪深300ETF期权的月度滚动数据都完整跑通了。这篇就以“基于Backtrader的指数期权备兑策略实现”为主线把框架选型、期权数据结构、换月实现、逐日盯市记账、回测指标拆解以及从回测跨到模拟盘和实盘时的改造思路一次讲清楚。适合两类人一是手里有宽基ETF持仓、想用备兑把仓位“盘活”的投资者二是想在本地用Python把期权策略做成可复现回测的量化开发。下文代码都是真实项目里拆出来的核心逻辑参数按常见情况给抄的时候记得用自己的数据校准。1. 为什么是Backtrader备兑策略回测框架选型的前因后果1.1 备兑策略最需要的回测能力是什么先说结论备兑策略的Alpha含量不高它赚的是期权的时间价值和波动率溢价真正吃功夫的是“记账”和“换月”不是复杂定价。所以回测框架最需要的核心能力只有三条能把ETF和期权两类标的放进同一个组合里算净值能在月度到期日、换月日这些固定时点精确触发动作能方便地插入手续费、滑点、行权交收这类事件型成本。这三条需求决定了选型方向不需要tick级撮合不需要希腊字母动态对冲更不需要偏微分方程求解。你只是每个月卖出一次Call到期前平掉或者被行权逻辑本身是离散的、低频的。Backtrader的事件驱动模型正好匹配这种节奏next()每天调用一次你在里面判断“今天是不是换月日、是不是到期日”就够了。1.2 与专用期权引擎、自研轮子相比Backtrader的取舍我周围不少朋友一提到期权回测就上vn.py的OptionMaster或者QuantLib这类重型工具实际体验下来是杀鸡用牛刀。OptionMaster功能确实全但它的数据结构、合约管理、Greek风控体系是为一整套期权做市系统准备的你只想回测一个Covered Call光是整理它的合约和做市数据就多出好几倍工作量。QuantLib更是偏向定价和风险计量拿来做策略事件驱动回测需要大量二次封装调试成本很高。自研事件循环我也试过最大的问题不是写不出来而是容易在细节上出错。期权回测的边界条件特别多到期日顺延、行权指派、跨月换仓、备兑证券锁定、结算价和收盘价的差异……这些逻辑如果全部手写在自研循环里每加一个功能就要重新回归一遍时间成本不划算。Backtrader的定位恰好卡在中间回测框架本身就帮你处理了订单队列、成交回报、净值计算这类通用事务而期权特有的东西你又可以在Strategy层面用几十行代码灵活覆盖不会被框架绑架。1.3 期权交易规则里决定数据结构的硬约束动手写代码之前有三条境内ETF期权市场的硬规则必须想清楚它们直接决定后面的数据表结构合约乘数目前境内上市的主要ETF期权50ETF、300ETF、500ETF等合约单位默认是10000份。也就是说你持有10000份ETF才有资格备兑开仓卖出一张Call。到期日ETF期权是每月第四个星期三到期遇到法定节假日顺延到下一个交易日。策略里如果用自然日判断到期碰到国庆、春节这类长假会错得离谱。备兑开仓卖出Call时不需要额外缴纳现金保证金前提是你把足额ETF锁定在备兑仓里。这对回测意味着持仓期间标的不存在保证金追缴问题组合的现金部分主要是权利金收支和交易费用。这三条规则理解了数据结构和策略逻辑才不会建歪。下面开始进入数据处理。2. 回测前的策略定义与数据结构合约选择、换月规则、数据源2.1 三个必须提前拍板的策略参数写代码之前先把策略参数定下来否则后面的调度表没法生成。第一标的选择。流动性是第一优先级优先选50ETF或者300ETF期权因为买卖价差小回测结果才可信。中证500ETF期权和中证1000ETF期权也可以做但部分月份合约深度不足回测容易失真。第二行权价档位。常见思路是卖平值或者虚值1~3档。虚值越深权利金越少但被行权概率越低平值权利金厚但换月时买回成本也高。后面第5节我会给出具体的敏感性结论这里先留个参数otm_pct。第三换月时点。两种主流做法到期日当天换月或者到期前3~5个交易日提前换月。提前换月能避开到期日附近的Gamma剧烈波动但会损失虚值期权最后几天的残余时间价值到期日当天换月能吃满Theta但遇到末日行情时买回成本可能突然放大。我最后固定在T-5日换月后面细说原因。2.2 原始数据结构与数据源期权原始行情需要记录每张合约的日线数据核心字段就七个字段含义trade_date交易日contract_code合约代码strike行权价expiry_date到期日close收盘价settle_price交易所公布的当日结算价volume / open_interest成交量 / 持仓量数据来源方面交易所官网会公布期权每日行情也可以用tushare pro的opt_daily这类接口直接拉取或者从第三方数据商购买。我个人的标准做法是落地成parquet文件按合约代码分区回测时按需加载不要每次都全量读。标的数据用ETF前复权日线这里有个坑会在第7节展开ETF分红会导致期权合约单位和行权价调整直接用原始行权价计算被行权收益会偏。2.3 用“合约调度表”把换月规则变成确定性的查表逻辑期权回测最忌讳在策略运行时实时扫描整条期权链因为Backtrader的next()是逐bar触发的每次去全市场筛选合约既慢又容易引入不一致。我的做法是在数据预处理阶段把换月规则写成确定性的函数对每个交易日生成一条记录当天该持仓哪张合约、它的结算价和收盘价是多少、这一天是不是换月日、是不是到期日。这张表我称之为“合约调度表”schedule。调度表的好处是回测和后续实盘共用同一套选约逻辑代码只写一次。策略在运行期只是查表不会因为数据源里多了一个新合约就改变行为可复现性非常强。代价是换月规则一旦定死做参数扫描时需要重新生成表但一次预处理也就几秒钟的事完全可接受。3. 把离散期权拼成连续K线滚动合约序列的构造与坑3.1 为什么不能直接把期权链扔给BacktraderBacktrader的Data Feed本质上是时间序列它要求你喂给它一根根连续的、有固定时间频率的K线。而期权是离散合约每个月一批行权价×到期日的组合同一个标的有几十上百张合约在交易到期前还有行情到期后直接退市归零。如果你把整条期权链全部加进Cerebro内存先受不了更麻烦的是策略里要自己维护“今天应该看哪张合约”的状态机极易出错。所以正确思路是预处理阶段直接构造一根“滚动合约序列”K线——每天策略实际持仓哪张合约就取那张合约当天的价格作为这根合成K线的价格。这样喂给Backtrader的只有两根数据一根ETF日线一根期权合成日线。策略逻辑瞬间清爽很多。3.2 滚动合约序列的构造代码下面这段是核心示意代码输入期权日线、ETF日线和换月参数输出调度表import pandas as pd def build_roll_schedule(etf_daily, opt_daily, roll_days5, otm_pct0.03): rows [] for date in etf_daily.index: # 当天所有未到期合约 chain opt_daily[(opt_daily.trade_date date) (opt_daily.expiry_date date)] if chain.empty: continue # 目标月份避开当月合约选择剩余期限约一个月以上的合约 next_exp chain[chain.expiry_date date pd.Timedelta(days21)] next_exp next_exp.expiry_date.min() candidates chain[chain.expiry_date next_exp] # 目标行权价比ETF收盘价高约otm_pct的那一档 diff (candidates.strike - etf_daily.loc[date, close]).abs() target candidates.loc[diff.idxmin()] rows.append({ trade_date: date, contract_code: target.contract_code, strike: target.strike, expiry_date: target.expiry_date, close: target.close, settle_price: target.settle_price, }) # 这里为了演示省略了到期倒计时的计算后面会补 roll_df pd.DataFrame(rows).set_index(trade_date) return roll_df注意这里pd.Timedelta(days21)只是一个粗略的“次月合约”筛选实际生产建议直接用交易日历数剩余交易日更准确。3.3 换月标记与到期标记的生成细节调度表生成之后最关键的步骤是打标记。换月日的判定不能简单用“合约代码和上一日不同”这种事后逻辑因为代码变化可能发生在非策略换月日。正确做法是给每个交易日计算“当前目标合约距离到期还剩几个交易日”当剩余天数等于roll_days时这一天就是换月日。到期标记同理当目标合约的到期日等于当天日期时标记为到期日。换月日当天还需要额外的价格字段旧合约的平仓价old_close和新合约的开仓价new_settle。由于调度表里新合约从换月日起才出现旧合约在换月日前后的连续性由调度逻辑维护这两个价格字段要在生成表时显式补上去。还有一个细节如果遇到节假日导致到期日顺延调度表的日期索引必须以交易所交易日历为准。我用的是上交所每年公布的休市安排生成完整交易日序列所有日期运算都在这个序列上做绝不用自然日。4. 策略核心实现手工盯市记账、换月平开、行权指派4.1 为什么期权仓位不能交给Backtrader原生brokerBacktrader的broker对股票类资产处理得非常好但对期权无能为力。它不知道“卖开”和“买平”的区别不知道期权到期归零更不知道备兑开仓时标的需要锁定。如果硬把期权当股票塞给broker你会发现组合权益的计算完全是错的。我采用的方案是纯手工记账期权仓位用策略内部变量记录期权逐日盯市盈亏用broker.add_cash()直接反映到总权益里。这样ETF部分仍然由broker正常处理期权部分由策略自己维护组合净值曲线天然就是真实的策略权益曲线。具体记账规则如下卖开一张Call不开仓不增加现金只记录当前权利金作为盯市基准同时扣手续费每个交易日计算今日结算价相对昨日结算价的变化短仓盈亏负的权利金变化×合约乘数用add_cash()入账买平了结按平仓价做最后一笔盯市调整再扣手续费到期实值被行权期权部分了结同时把对应10000份ETF按行权价交割到期虚值期权归零盯市盈亏已经逐日体现在权益里不需要额外动作。4.2 CoveredCall策略完整代码import backtrader as bt class CoveredCall(bt.Strategy): params dict( roll_days5, # 到期前第N个交易日换月 commission2.0, # 每张期权手续费元 multiplier10000, # 合约乘数 etf_units10000, # 初始ETF持仓份数 ) def __init__(self): self.schedule self.p.schedule # 调度表indextrade_date self.opt_position 0 # -1表示当前卖开一张 self.mark_price None # 上一盯市基准前结算价 self.assignment_pending False # 被行权后待调整标记 self.assignment_strike 0.0 def start(self): if self.broker.getposition(self.datas[0]).size 0: self.buy(dataself.datas[0], sizeself.p.etf_units, exectypebt.Order.Market) def next(self): date self.datas[0].datetime.date(0) if date not in self.schedule.index: return row self.schedule.loc[date] # 1) 逐日盯市短仓盈亏 权利金下降带来的收益 if self.opt_position -1 and self.mark_price is not None: mtm -(row[settle_price] - self.mark_price) * self.p.multiplier self.broker.add_cash(mtm) # 2) 换月日平旧开新 if row.get(is_roll_day, False) and self.opt_position -1: self.close_option(row[old_close]) self.open_option(row[new_settle]) # 3) 到期日当前持仓合约恰好到期 if row.get(is_expiry_day, False) and self.opt_position -1: if row[itm_at_expiry]: # 实值按行权价交割。先用期权内在价值平仓再卖ETF self.close_option(row[expiry_intrinsic]) self.assignment_pending True self.assignment_strike row[strike] self.sell(dataself.datas[0], sizeself.p.etf_units, exectypebt.Order.Market) else: # 虚值到期归零处理盯市盈亏已体现在权益里 self.opt_position 0 self.mark_price None # 4) 每根bar结束更新盯市基准 if self.opt_position -1: self.mark_price row[settle_price] def close_option(self, close_price): pnl (self.mark_price - close_price) * self.p.multiplier self.broker.add_cash(pnl - self.p.commission) self.opt_position 0 self.mark_price None def open_option(self, premium): self.broker.add_cash(-self.p.commission) self.opt_position -1 self.mark_price premium def notify_order(self, order): if order.status ! order.Completed: return if self.assignment_pending and order.data is self.datas[0]: # 真实交割按行权价执行和ETF市价成交之间补差额 diff (self.assignment_strike - order.executed.price) * self.p.etf_units self.broker.add_cash(diff) self.assignment_pending False4.3 盯市、换月、行权三个关键分支的解释盯市这一步是很多自己写期权回测的人最容易漏的。如果只在换月或到期时结算一次权利金期间ETF下跌时组合净值不会体现期权短仓的浮盈导致最大回撤被高估反之ETF上涨时也不会体现期权短仓的浮亏导致收益虚高。用settle_price逐日盯市组合净值曲线的形态才接近真实账户。换月分支里old_close是旧合约平仓价new_settle是新合约的结算价。这里使用了结算价作为开仓成交参考但实际回测中开仓成交价会更接近收盘价两者差异要单独考虑滑点。我建议先把回测做成“按结算价成交”的上限版本再叠加滑点做下限版本两个区间就能看出参数是否稳健。行权分支里实值到期的处理用了“内在价值平仓ETF市价卖出差额调整”三步。内在价值就是max(strike - underlying, 0)对于Call来说实值意味着要被行权。ETF卖出后notify_order里再把行权价和市场成交价的差额补进现金最终组合的现金变化和真实交割一致。4.4 边界情况节假日顺延、首月建仓、被行权的差额调整节假日顺延的坑上面说过调度表用交易日历生成即可。首月建仓要注意如果回测起始日不是换月日需要先按当日调度表平开一张当月合约否则策略第一个月是“裸持ETF”状态。被行权的差额调整依赖notify_order能拿到实际成交价格这要求下一根bar时ETF订单已经成交如果使用市价单且回测里成交量足够大基本都能在当根bar撮合但用限价单时要小心可能延迟到第二根bar成交。5. 回测结果怎么读收益归因与参数敏感性5.1 分项收益对比表回测跑完第一件事不是看总收益而是拆收益来源。下面这组数据是我用沪深300ETF期权近三年的日线数据跑出来的示例结果参数是虚值3%、到期前5日换月、每张手续费2元数值仅供参考不同时间窗口差异会很大指标纯持有300ETF备兑T-5换月备兑到期日换月年化收益6.3%8.4%7.8%最大回撤-17.6%-15.1%-15.8%年化波动率19.2%16.8%17.4%权利金年化增厚—2.1%1.5%从这张表能读出两个稳定规律第一备兑策略确实能降低回撤和波动率但幅度有限第二权利金增厚才是超额收益的主要来源。ETF本身大跌时权利金只能提供两三个点的缓冲指望备兑“对冲大跌”是不现实的。5.2 备兑的收益本质卖出波动率很多人以为备兑是“增强收益的无风险策略”这是误解。备兑的收益本质是卖出波动率当隐含波动率高于未来实际波动率时卖期权的人稳定赚取价差当IV被市场预期推得过高时权利金很厚备兑增厚更明显但当IV处于低位时权利金收入很薄甚至可能被交易成本吃光。我建议回测时把样本区间按IV分位切开看。我自己的经验是IV处于历史分位20%以上时备兑增厚稳定为正IV低于20分位时增厚接近零甚至为负。如果你回测选了一段低波动的年份千万不要把结果外推到高波动年份反之亦然。5.3 我对几个参数的实测结论行权价档位方面平值权利金收入高但换月时买回成本也高一个月滚下来净收入反而不如虚值2%~3%。虚值5%以上权利金太少增厚意义不大。换月时点方面T-1换月能多吃几天Theta但碰到单边大跌的月份到期日附近的Gamma风险会让买回成本暴增所以我最终固定在T-3到T-5。手续费方面ETF期权手续费每张从1.7元到10元不等按10000份合约乘数折算单张成本看起来不起眼但一个月平开两次、一年24次2元/张的成本累计约48元对一手权利金500~800元来说占了6%~10%。手续费超过5元/张时行权价档位要往深虚值调不然净增厚会被手续费吃掉。6. 从回测到模拟盘与多标的多合约扩展6.1 三层架构改造Backtrader毕竟是个回测框架直接拿它连实盘柜台并不现实。但好消息是策略层可以几乎原封不动地搬到实盘链路里关键是把架构拆成三层数据层回测用历史日线模拟盘用增量行情。Backtrader的Data Feed支持自定义load()可以从行情接口实时拉数据。策略层上面的CoveredCall类基本不动把“开平仓”改为调用一个信号接口输出标准信号比如“卖出开仓合约代码XXX数量1张”。执行层信号翻译成柜台委托。境内ETF期权程序化交易常见的接入方式有券商极速柜台比如XTP、奇点这类接口或者券商提供的终端自动化组件迷你QMT、PTrade等。Backtrader官方虽然自带部分实盘broker但期权这种特殊产品基本都要自己写一个BrokerAdapter把策略里的订单意图翻译成具体合约的买卖方向。6.2 模拟盘比回测多出来的三类坑回测里我直接用add_cash()记账但模拟盘执行层必须处理三类回测代码可以忽略的问题合约状态已退市、停牌、即将到期不能开仓、开仓时保证金/备兑仓检查、到期日实值持仓被券商自动行权。我建议模拟盘先跑至少两个完整的到期周期专门盯“换月日下单时序”“到期日持仓处理”“节假日顺延”这三类逻辑过了两个月再考虑实盘。6.3 多标的扩展Backtrader多数据源的正确姿势很多人做备兑不止做一只标的而是把50ETF、300ETF、500ETF都纳入备兑池。Backtrader的多标的多股票回测天生支持cerebro.adddata()可以加多根数据策略里用self.datas[i]区分标的。关键在于期权调度表要用“标的代码日期”做复合索引策略运行时根据当前标的的_name去取对应的调度表。简化的多数据骨架import backtrader as bt cerebro bt.Cerebro() for symbol in [510050, 510300]: data bt.feeds.PandasData(datanameetf_dict[symbol]) cerebro.adddata(data, namesymbol) cerebro.addstrategy(CoveredCall, scheduleschedule_dict) cerebro.run()策略内部取标的名字时要注意self.datas[0]._name能拿到Cerebro里配置的名称再用这个名字去schedule_dict里查表。不要假设所有数据每天都有K线某只ETF停牌时它的bar会缺失next()里要加if len(self.datas[0]) 0这类防御。多标的模型还有一个好处如果某张合约流动性差可以在调度表生成阶段就把它剔除让资金自动分流到其它标的的备兑仓。6.4 性能与部署日线级别的备兑策略对性能要求极低瓶颈从来不在Backtrader本身而在执行层的委托速度和柜台返回速度。尤其是到期日前后几分钟券商柜台通道经常拥堵信号要提前排队。我实测下来日线级别策略跑十年数据也就几秒钟真正耗时的反而是期权链数据预处理所以调度表生成完全可以离线预计算盘中只做查表。7. 最后再分享几条实操经验7.1 盯市价格用结算价而不是收盘价前面代码里我特别区分了close和settle_price。实盘中Z账户的日终盯市和保证金计算都按交易所公布的结算价来不用收盘价。回测里如果一律用收盘价盯市净值曲线的日内波动会略大于真实账户。但开平仓成交价又是另一回事建议用收盘价加滑点估计更接近实际可成交价位。7.2 被行权的建模要务实境内ETF期权到期日15:30前可以申报行权券商对实值合约的自动行权规则不尽相同。回测里假设“实值必被行权、按行权价结算”其实是乐观偏差。现实中有人会放弃行权、提前平仓、或者因为账户资金不足没法行权这些都会造成偏差。更稳妥的建模是到期前一日收盘后判断实值程度如果深度实值就按行权价交割如果轻度实值则假设到期日开盘平仓。这样回测结果更保守实盘表现不容易低于预期。7.3 交易成本要清单化我吃过最大的亏是漏算ETF卖出印花税。目前证券交易印花税按卖出方向征收减半后是0.05%备兑策略被行权时要把10000份ETF卖出去一年下来累计金额不小。期权本身不收印花税但有交易所经手费和结算费这些都要在佣金模型里明细列出来。建议写一个cost_ledger把每次交易的佣金、印花税、滑点分别记录回测结束后直接按科目归因比在净值里“感觉差不多”靠谱得多。7.4 自检清单回测上线前我通常会过一遍清单调度表是否覆盖完整交易日历换月日是否精确到“到期前第N个交易日”期权结算价是否取自对应合约当日ETF分红除息是否导致合约乘数变化被行权的差额调整是否在notify_order中正确入账手续费参数是否区分买方和卖方。这六项都确认过我才会把回测结果作为实盘参数选择的依据。以上是我在完成这个策略过程中沉淀下来的主要做法里面每一个“坑”都是真金白银换来的教训写出来供同路人参考。
返回列表