裂解价差套利策略
一、交易策略解释
核心思想
经典意义上的裂解价差(Crack Spread),通常用于刻画原油与下游成品油之间的加工利润变化。在国际市场中,这一指标往往围绕原油、汽油和柴油等品种展开;而在国内期货市场中,可交易品种的挂牌结构并不完全一致,因此在策略研究中,更常见的做法是选取原油与燃料油系品种来构建一个可交易、可回测的炼化利润代理价差。
本文采用以下三类合约作为观察对象:
- 原油期货(SC):代表原料端;
- 燃料油期货(FU):代表重质燃料油端;
- 低硫燃料油期货(LU):代表低硫船燃端。
基于这组三个品种,策略关注的并非单一商品价格的绝对涨跌,而是:
- 下游燃料油系价格是否相对原油明显偏强;
- 或者下游燃料油系价格是否相对原油明显偏弱;
- 当这种偏离超出历史常态后,是否存在向均衡区间回归的可能。
因此,这一策略更适合被理解为:
- 国内期货市场下的裂解利润代理价差策略;
- 或者更直接地说,是原油—燃料油系加工价差策略。
指标口径说明
为了让策略既保留产业含义,又能够在国内品种体系下落地,本文采用的是一个代理利润指标,而不是海外市场常见的标准 3:2:1 裂解价差原型。
本文所用的代理价差定义为:
- 炼化利润代理价差(元/吨) = 0.5 × 低硫燃料油价格 + 0.5 × 燃料油价格 - 原油折吨价格
其中:
- 燃料油与低硫燃料油本身采用 元/吨 报价;
- 原油期货采用 元/桶 报价,因此需要先折算为 元/吨;
- 文中使用的折算系数为一个研究口径上的近似值,用于将原油价格与下游燃料油系价格统一到同一计量体系中。
这一处理方式的意义在于:
- 保留“原料端 vs 下游产品端”的相对价值比较;
- 避免直接把不同单位的价格机械相减;
- 使价差序列更适合做历史统计与均值回归分析。
同时也需要明确,这一指标是研究与交易上的代理口径,并不等同于炼厂财务报表中的完整加工利润。它未纳入:
- 加工损耗与不同装置收率差异;
- 船运、仓储、税费与调和成本;
- 区域现货升贴水与交割品品质差异。
理论基础
-
原料端与产品端联动: 原油构成炼化链条的上游成本基础,而燃料油、低硫燃料油则反映下游燃料市场的价格变化,因此三者之间天然存在成本传导关系。
-
阶段性供需错配: 当原油供应扰动、船燃需求波动、炼厂检修、库存变化或出口节奏调整出现时,原料端与产品端的价格变化速度并不一致,容易形成阶段性偏离。
-
均值回归特征: 当下游产品端相对原油明显偏强时,市场通常会重新定价加工利润预期;当下游产品端相对原油明显偏弱时,利润压缩也可能引发修复预期。这种来回摆动,构成了价差回归策略的基础。
指标口径与执行口径
1)指标计算口径:统一到“元/吨”
策略信号使用的是统一后的吨口径价格:
spread = 0.5 * lu_price + 0.5 * fu_price - sc_price_ton
其中:
lu_price、fu_price为下游产品价格(元/吨);sc_price_ton为原油价格由元/桶折算后的元/吨价格。
这一层对应的是产业研究口径下的相对价值比较。
2)执行口径:按合约手数换算
实际交易中,头寸仍需按交易所合约手数表达。因此在执行阶段,需要把原油侧的桶数与燃料油侧的吨数做进一步映射,再换算为对应手数。
如果以原油下单 N 手为基准,则:
- 先将原油手数换算为桶数;
- 再将桶数折算为原油吨数;
- 最后根据下游产品篮子的权重,将其分配到 FU 与 LU 两条腿,并换算为对应手数。
3)组合执行中的残余敞口
由于期货交易以整数手为单位,理论吨数很难与合约手数完全一一对应,因此实际组合执行时通常会保留一定近似误差。这也是代理价差策略在落地时需要同步关注的执行细节。
策略适用场景
- 原油与燃料油系品种之间出现阶段性强弱错位;
- 历史价差呈现一定的均值回归特征;
- 固定合约或近月合约具有较好的流动性;
- 交易者接受代理口径与整数手换算带来的执行偏差。
二、天勤介绍
天勤平台概述
天勤(TqSdk)是一个由信易科技开发的开源量化交易系统,为期货、期权等衍生品交易提供专业的量化交易解决方案。平台具有以下特点:
- 丰富的行情数据: 支持多品种行情订阅与 K 线数据获取,便于构建跨品种价差指标;
- 一站式的解决方案: 覆盖研究、回测、模拟与实盘各个环节;
- 专业的技术支持: 便于结合 pandas 与 numpy 快速完成指标构建与执行逻辑开发。
策略开发流程
- 环境准备
- 安装 Python 环境;
- 安装
tqsdk:pip install tqsdk; - 配置账户认证信息。
- 数据准备
- 订阅 SC、FU、LU 三个目标合约;
- 获取日线数据用于构建代理价差及历史分布。
- 策略编写
- 将原油价格折算为吨口径;
- 构建原油—燃料油系代理价差;
- 结合 Z-score 完成信号与三腿组合执行。
- 回测验证
- 设置固定合约与回测区间;
- 观察价差回归节奏、组合配平与收益曲线;
- 评估收益率、回撤及稳定性。
- 策略优化
- 调整入场、离场和止损阈值;
- 调整原油折吨系数与下游产品篮子权重;
- 引入更明确的换月与流动性过滤规则。
三、天勤实现策略
策略原理
核心价差定义
本文采用的信号指标为原油—燃料油系炼化利润代理价差:
spread_t = 0.5 × LU_t + 0.5 × FU_t - SC_ton
其中:
LU_t:低硫燃料油价格(元/吨)FU_t:燃料油价格(元/吨)SC_ton:原油价格由元/桶折算后的元/吨价格
这一定义的重点不在于完全复制海外标准裂解模型,而在于用国内可交易合约构建一个稳定、清晰、可执行的炼化利润代理序列。
指标构建
策略采用 Z-score 衡量当前价差相对历史分布的偏离程度:
z_score = (current_spread - mean_spread) / std_spread
具体做法如下:
- 使用最近
LOOKBACK_DAYS个已完成日线构建历史窗口; - 逐日计算代理价差序列;
- 计算历史均值和标准差;
- 将最新一个已完成日线的价差代入,得到当前 Z-score;
- 若标准差过小,则跳过本次信号,避免极端放大。
下单手数换算
交易执行时,以原油腿作为基准手数:
sc_barrels = sc_lots × sc_volume_multiple
crude_tons = sc_barrels / barrels_per_ton
fu_lots = round(crude_tons × 0.5 / fu_volume_multiple)
lu_lots = round(crude_tons × 0.5 / lu_volume_multiple)
这样处理的目的,是在代理价差研究口径与实际下单口径之间建立一致的映射关系。
交易逻辑
开仓信号:
- 卖出代理价差: 当
z_score > ENTRY_Z,说明下游燃料油系相对原油偏强,预期价差向下回归;- 操作:买入原油,卖出燃料油与低硫燃料油;
- 买入代理价差: 当
z_score < -ENTRY_Z,说明下游燃料油系相对原油偏弱,预期价差向上修复;- 操作:卖出原油,买入燃料油与低硫燃料油。
平仓信号:
- 当
abs(z_score) < EXIT_Z时,认为价差已回到正常区间,组合平仓。
止损信号:
- 若已持有多头价差,但
z_score继续上冲至STOP_Z以上; - 若已持有空头价差,但
z_score继续下破至-STOP_Z以下; - 则执行强制平仓。
回测说明
回测初始设置
- 测试周期: 2024 年 3 月 26 日 - 2024 年 4 月 17 日
- 交易品种:
INE.sc2405/SHFE.fu2405/INE.lu2405 - 初始资金: 1000 万元
回测口径说明
本次回测采用阶段性展示窗口,主要用于呈现:
- 国内裂解利润代理价差的构建方式;
- 原油桶口径向燃料油吨口径的映射逻辑;
- 三腿组合在 TqSdk 框架中的执行方式。
因此,本文采用的是固定远月合约口径,而非主力连续或自动换月口径。这样的处理有助于保持三个品种在同一交割月下进行观察,也便于对价差本身进行稳定比较。
同时,这一口径也有其边界:
- 结果仍会受到远月流动性、期限结构和固定合约选择的影响;
- 若用于更贴近实盘的策略评估,还需进一步加入换月安排、执行成本和流动性约束。
回测结果解读
下图展示的是一段阶段性表现窗口,更适合作为策略结构与执行逻辑的展示参考,主要用于观察:
- 代理价差是否具备一定的均值回归特征;
- 三腿组合能否实现相对稳定的配平;
- 组合执行后是否存在较明显的残余敞口。
回测结果

上述回测累计收益走势图

完整代码示例
#!/usr/bin/env python
# coding=utf-8
__author__ = "Chaos"
from datetime import date
import numpy as np
from tqsdk import TqApi, TqAuth, TqBacktest, TqSim, TargetPosTask, BacktestFinished
# === 用户参数 ===
CRUDE_OIL = "INE.sc2405" # 原油期货合约
FUEL_OIL = "SHFE.fu2405" # 燃料油期货合约
LS_FUEL = "INE.lu2405" # 低硫燃料油期货合约
START_DATE = date(2024, 3, 26)
END_DATE = date(2024, 4, 17)
LOOKBACK_DAYS = 20
DATA_LENGTH = LOOKBACK_DAYS + 2
ENTRY_Z = 1.5
EXIT_Z = 0.3
STOP_Z = 2.5
SC_ORDER_LOTS = 6
# 研究口径参数
CRUDE_BARRELS_PER_TON = 7.33 # 将原油价格由元/桶折算为元/吨的近似系数
FU_WEIGHT = 0.5
LU_WEIGHT = 0.5
def calc_proxy_margin(sc_price_barrel, fu_price_ton, lu_price_ton):
"""按吨口径计算原油-燃料油系炼化利润代理价差(元/吨)"""
sc_price_ton = sc_price_barrel * CRUDE_BARRELS_PER_TON
product_basket_ton = FU_WEIGHT * fu_price_ton + LU_WEIGHT * lu_price_ton
return product_basket_ton - sc_price_ton
api = TqApi(
TqSim(),
backtest=TqBacktest(start_dt=START_DATE, end_dt=END_DATE),
auth=TqAuth("快期账号", "快期密码"),
)
sc_quote = api.get_quote(CRUDE_OIL)
fu_quote = api.get_quote(FUEL_OIL)
lu_quote = api.get_quote(LS_FUEL)
sc_klines = api.get_kline_serial(CRUDE_OIL, 60 * 60 * 24, data_length=DATA_LENGTH)
fu_klines = api.get_kline_serial(FUEL_OIL, 60 * 60 * 24, data_length=DATA_LENGTH)
lu_klines = api.get_kline_serial(LS_FUEL, 60 * 60 * 24, data_length=DATA_LENGTH)
sc_pos_task = TargetPosTask(api, CRUDE_OIL)
fu_pos_task = TargetPosTask(api, FUEL_OIL)
lu_pos_task = TargetPosTask(api, LS_FUEL)
while not (sc_quote.volume_multiple and fu_quote.volume_multiple and lu_quote.volume_multiple):
api.wait_update()
sc_volume_multiple = sc_quote.volume_multiple # 1000 桶/手
fu_volume_multiple = fu_quote.volume_multiple # 10 吨/手
lu_volume_multiple = lu_quote.volume_multiple # 10 吨/手
# 根据原油腿的基准手数,换算下游产品腿的理论手数,并取最近整数手
sc_barrels_per_order = SC_ORDER_LOTS * sc_volume_multiple
crude_tons_per_order = sc_barrels_per_order / CRUDE_BARRELS_PER_TON
fu_lots_per_order = int(round(crude_tons_per_order * FU_WEIGHT / fu_volume_multiple))
lu_lots_per_order = int(round(crude_tons_per_order * LU_WEIGHT / lu_volume_multiple))
fu_residual_tons = crude_tons_per_order * FU_WEIGHT - fu_lots_per_order * fu_volume_multiple
lu_residual_tons = crude_tons_per_order * LU_WEIGHT - lu_lots_per_order * lu_volume_multiple
print(
f"策略启动,监控合约: {CRUDE_OIL}, {FUEL_OIL}, {LS_FUEL}\n"
f"每次下单:原油 {SC_ORDER_LOTS} 手,燃料油 {fu_lots_per_order} 手,低硫燃料油 {lu_lots_per_order} 手\n"
f"近似配比残余:燃料油 {fu_residual_tons:.2f} 吨,低硫燃料油 {lu_residual_tons:.2f} 吨"
)
def build_completed_spread_series():
"""只使用已完成日线,排除当前正在形成的最后一根 K 线"""
sc_close = np.array(sc_klines.close.iloc[:-1], dtype=float)
fu_close = np.array(fu_klines.close.iloc[:-1], dtype=float)
lu_close = np.array(lu_klines.close.iloc[:-1], dtype=float)
valid = np.isfinite(sc_close) & np.isfinite(fu_close) & np.isfinite(lu_close)
sc_close = sc_close[valid]
fu_close = fu_close[valid]
lu_close = lu_close[valid]
return calc_proxy_margin(sc_close, fu_close, lu_close)
def get_net_pos(symbol):
pos = api.get_position(symbol)
return pos.pos_long - pos.pos_short
last_completed_dt = None
try:
while True:
api.wait_update()
new_bar = (
api.is_changing(sc_klines.iloc[-1], "datetime")
or api.is_changing(fu_klines.iloc[-1], "datetime")
or api.is_changing(lu_klines.iloc[-1], "datetime")
)
if not new_bar:
continue
completed_dt = min(
sc_klines.iloc[-2]["datetime"],
fu_klines.iloc[-2]["datetime"],
lu_klines.iloc[-2]["datetime"],
)
if completed_dt == last_completed_dt:
continue
last_completed_dt = completed_dt
spreads = build_completed_spread_series()
if len(spreads) < LOOKBACK_DAYS + 1:
continue
history_window = spreads[-LOOKBACK_DAYS - 1:-1]
current_spread = spreads[-1]
mean_spread = np.mean(history_window)
std_spread = np.std(history_window)
if std_spread < 1e-8:
print("历史标准差过小,跳过本次信号")
continue
z_score = (current_spread - mean_spread) / std_spread
print(f"{completed_dt} 当前代理裂解价差: {current_spread:.2f}, Z-score: {z_score:.2f}")
sc_net = get_net_pos(CRUDE_OIL)
fu_net = get_net_pos(FUEL_OIL)
lu_net = get_net_pos(LS_FUEL)
has_position = any(pos != 0 for pos in [sc_net, fu_net, lu_net])
if not has_position:
if z_score > ENTRY_Z:
# 卖出代理价差:买入原油,卖出燃料油与低硫燃料油
print(
f"开仓-卖出代理价差:买入原油 {SC_ORDER_LOTS} 手,"
f"卖出燃料油 {fu_lots_per_order} 手,低硫燃料油 {lu_lots_per_order} 手"
)
sc_pos_task.set_target_volume(SC_ORDER_LOTS)
fu_pos_task.set_target_volume(-fu_lots_per_order)
lu_pos_task.set_target_volume(-lu_lots_per_order)
elif z_score < -ENTRY_Z:
# 买入代理价差:卖出原油,买入燃料油与低硫燃料油
print(
f"开仓-买入代理价差:卖出原油 {SC_ORDER_LOTS} 手,"
f"买入燃料油 {fu_lots_per_order} 手,低硫燃料油 {lu_lots_per_order} 手"
)
sc_pos_task.set_target_volume(-SC_ORDER_LOTS)
fu_pos_task.set_target_volume(fu_lots_per_order)
lu_pos_task.set_target_volume(lu_lots_per_order)
else:
# 注意:TargetPosTask 发出目标持仓后,真正报单与成交推进依赖后续 wait_update()
if abs(z_score) < EXIT_Z:
print("价差回归正常区间,平仓所有头寸")
sc_pos_task.set_target_volume(0)
fu_pos_task.set_target_volume(0)
lu_pos_task.set_target_volume(0)
elif sc_net < 0 and z_score > STOP_Z:
print("止损:买入代理价差后,价差继续向不利方向偏离")
sc_pos_task.set_target_volume(0)
fu_pos_task.set_target_volume(0)
lu_pos_task.set_target_volume(0)
elif sc_net > 0 and z_score < -STOP_Z:
print("止损:卖出代理价差后,价差继续向不利方向偏离")
sc_pos_task.set_target_volume(0)
fu_pos_task.set_target_volume(0)
lu_pos_task.set_target_volume(0)
except BacktestFinished:
print("回测结束")
finally:
api.close()