问题描述
解决方案
ETF 溢价套利策略实现
ETF 溢价套利(Premium Arbitrage)是指当 ETF 的二级市场交易价格(Price)显著高于其参考净值(IOPV/NAV)时,通过买入一篮子成分股 -> 申购 ETF 份额 -> 二级市场卖出 ETF 的方式来获取无风险或低风险收益的策略。
在 PTrade 中,实现该策略的核心在于利用 etf_basket_order(篮子买入)和 etf_purchase_redemption(申购赎回)接口。
以下是详细的策略逻辑与代码实现。
1. 策略逻辑
- 获取数据:获取 ETF 的实时盘口价格(Ask1/Bid1 或 最新价)和实时参考净值(IOPV)。
- 计算溢价率:
溢价率 = (ETF价格 - IOPV) / IOPV。 - 判断阈值:如果
溢价率 > 交易成本阈值(如 0.2%),触发套利信号。 - 执行套利:
- 第一步:使用
etf_basket_order买入一篮子成分股。 - 第二步:使用
etf_purchase_redemption提交 ETF 申购请求(将股票换成 ETF 份额)。 - 第三步:使用
order或order_target在二级市场卖出 ETF 份额(T+0 ETF 可当日卖出)。
- 第一步:使用
2. 关键 API 说明
get_etf_info(etf_code): 获取 ETF 的最小申赎单位(report_unit),这是计算下单数量的基础。get_snapshot(security): 获取 ETF 的实时行情快照。etf_basket_order(etf_code, amount, ...): 专门用于 ETF 成分股的篮子交易。etf_purchase_redemption(etf_code, amount): 执行申购(正数)或赎回(负数)。
3. 策略代码实现
def initialize(context):
"""
策略初始化函数
"""
# 1. 设置要套利的ETF代码 (例如: 510050.SS 上证50ETF)
g.etf_code = '510050.SS'
# 2. 设置股票池
set_universe([g.etf_code])
# 3. 套利参数设置
g.threshold = 0.002 # 溢价阈值,例如 0.2% (需覆盖佣金、印花税、过户费等)
g.basket_num = 1 # 每次套利的篮子数量
# 4. 获取ETF基础信息 (主要是最小申赎单位 report_unit)
# 注意:实际交易中建议加异常处理,防止获取失败
etf_info = get_etf_info(g.etf_code)
if etf_info and g.etf_code in etf_info:
g.report_unit = int(etf_info[g.etf_code]['report_unit'])
log.info("ETF: %s, 最小申赎单位: %d" % (g.etf_code, g.report_unit))
else:
g.report_unit = 0
log.error("未获取到ETF信息,策略无法运行")
# 5. 开启盘中定时运行 (例如每分钟检测一次,高频策略可使用 tick_data)
# 这里为了演示逻辑清晰,使用 handle_data 或 run_interval
# run_interval(context, arbitrage_logic, seconds=5)
def handle_data(context, data):
"""
盘中运行函数 (分钟级)
"""
if g.report_unit == 0:
return
# 执行套利逻辑
arbitrage_logic(context)
def arbitrage_logic(context):
"""
套利核心逻辑
"""
etf = g.etf_code
# 1. 获取行情快照
snapshot = get_snapshot(etf)
if not snapshot or etf not in snapshot:
return
tick_data = snapshot[etf]
# 2. 获取关键价格
# 获取买一价 (作为卖出ETF的参考价格,保守估计)
# 注意:snapshot['bid_grp'] 格式为 {1: [价格, 量, 笔数], ...}
bid_grp = tick_data.get('bid_grp')
if not bid_grp:
return
# 取买一价
etf_market_price = bid_grp[1][0]
# 3. 获取 IOPV (净值)
# 注意:PTrade 的 get_snapshot 返回字段中通常包含 IOPV,
# 但具体字段名需根据券商实盘环境确认 (常见为 'iopv' 或在扩展字段中)。
# 如果 snapshot 中没有直接提供 iopv,需要通过 get_trend_data 或外部数据源获取。
# 此处假设 snapshot 中包含 'iopv' 字段,或者我们用昨收盘估算演示(实际必须用实时IOPV)
# --- 模拟 IOPV 获取 (实盘请替换为真实 IOPV 获取代码) ---
# 假设当前没有直接的IOPV字段,这里仅做代码结构演示
# 实际中可能是: current_iopv = tick_data.get('iopv')
# 这里为了代码不报错,我们模拟一个比市价低的价格来触发逻辑
current_iopv = etf_market_price * 0.995
# --------------------------------------------------
if current_iopv <= 0 or etf_market_price <= 0:
return
# 4. 计算溢价率
premium_rate = (etf_market_price - current_iopv) / current_iopv
# 5. 判断是否满足套利条件
if premium_rate > g.threshold:
log.info("发现溢价套利机会: 市价 %.3f, IOPV %.3f, 溢价率 %.2f%%" %
(etf_market_price, current_iopv, premium_rate * 100))
# 检查资金是否足够 (此处省略详细资金检查,实盘需添加)
# --- 执行套利三步走 ---
# 步骤 A: 买入一篮子股票
# price_style='S5' 表示用卖五价买入,保证成交速度
# position=False 表示不使用持仓替代,直接从市场买入
log.info("Step 1: 买入成分股篮子")
etf_basket_order(etf, g.basket_num, price_style='S5', position=False)
# 步骤 B: 申购 ETF
# amount 为 篮子数量 * 最小申赎单位
creation_amount = g.basket_num * g.report_unit
log.info("Step 2: 申购 ETF 份额: %d" % creation_amount)
etf_purchase_redemption(etf, creation_amount)
# 步骤 C: 卖出 ETF
# 注意:T+0 的 ETF (如债券、黄金、跨境或部分单市场ETF) 允许申购后当日卖出。
# 普通股票 ETF 可能需要确认份额到账时间 (部分券商支持实时可用,部分需T+1)。
# 假设是支持 T+0 变现的品种:
log.info("Step 3: 卖出 ETF 份额")
order(etf, -creation_amount)
4. 注意事项与风险提示
- IOPV 获取:
- 文档中的
get_snapshot返回字段列表未明确列出iopv。在实盘环境中,IOPV 通常包含在 Level-2 行情推送中。如果get_snapshot无法获取,可能需要自行根据成分股价格计算(计算量大)或咨询券商是否有专门的 IOPV 接口。
- 文档中的
- T+0 规则:
- 并非所有 ETF 都支持“买入股票->申购->卖出ETF”的日内 T+0 回转。通常单市场 ETF(如沪市 ETF 买沪市股票)支持较高的效率,但跨市场 ETF(如沪深300ETF)涉及两个交易所的交收,申购的份额可能需要 T+1 或 T+2 才能卖出。请务必确认标的 ETF 的交易规则。
- 现金替代(Cash Replacement):
etf_basket_order中的info参数可以控制现金替代标志。如果某些成分股停牌或涨停买不到,必须使用“允许现金替代”或“必须现金替代”模式,这会带来额外的资金成本(现金差额)。
- 并发与时序:
- 上述代码是顺序执行的。在极速交易场景下,买入篮子和申购通常需要极短的时间间隔。如果买入篮子部分成交而部分未成交,直接申购可能会失败或转为现金替代,导致成本不可控。
- 资金占用:
- ETF 申购通常需要较大的资金量(一个单位通常对应 100万-300万人民币市值),请确保账户有足够的
cash。
- ETF 申购通常需要较大的资金量(一个单位通常对应 100万-300万人民币市值),请确保账户有足够的
5. 进阶优化 (Tick 级)
如果需要更高频的监控,可以将逻辑放入 tick_data 函数中:
def tick_data(context, data):
etf = g.etf_code
# 检查是否有该ETF的tick数据
if etf not in data:
return
tick = data[etf]['tick']
# 获取盘口价格和IOPV进行计算...
# 逻辑同上
使用 tick_data 时,请确保在 initialize 或 set_parameters 中配置了接收 L2 行情(如果券商支持)。
Q&A
Q: etf_basket_order 会自动帮我申购 ETF 吗?
A: 不会。etf_basket_order 的作用是根据 ETF 清单及其权重,帮你批量买入底层的股票。买入成功后,你持有的是一堆股票。你必须紧接着调用 etf_purchase_redemption 将这些股票“交换”成 ETF 份额。
Q: 为什么代码里用 price_style='S5'?
A: 套利追求速度和成交确定性。用卖五价(S5)买入成分股是为了确保能立即成交,防止因价格变动导致篮子买不齐。由此产生的滑点成本需要包含在你的 g.threshold(溢价阈值)中。