裂解价差套利策略

一、交易策略解释

核心思想

经典意义上的裂解价差(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_pricefu_price 为下游产品价格(元/吨);
  • sc_price_ton 为原油价格由元/桶折算后的元/吨价格。

这一层对应的是产业研究口径下的相对价值比较。

2)执行口径:按合约手数换算

实际交易中,头寸仍需按交易所合约手数表达。因此在执行阶段,需要把原油侧的桶数与燃料油侧的吨数做进一步映射,再换算为对应手数。

如果以原油下单 N 手为基准,则:

  • 先将原油手数换算为桶数;
  • 再将桶数折算为原油吨数;
  • 最后根据下游产品篮子的权重,将其分配到 FU 与 LU 两条腿,并换算为对应手数。

3)组合执行中的残余敞口

由于期货交易以整数手为单位,理论吨数很难与合约手数完全一一对应,因此实际组合执行时通常会保留一定近似误差。这也是代理价差策略在落地时需要同步关注的执行细节。

策略适用场景

  • 原油与燃料油系品种之间出现阶段性强弱错位;
  • 历史价差呈现一定的均值回归特征;
  • 固定合约或近月合约具有较好的流动性;
  • 交易者接受代理口径与整数手换算带来的执行偏差。

二、天勤介绍

天勤平台概述

天勤(TqSdk)是一个由信易科技开发的开源量化交易系统,为期货、期权等衍生品交易提供专业的量化交易解决方案。平台具有以下特点:

策略开发流程

  • 环境准备
    • 安装 Python 环境;
    • 安装 tqsdkpip 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

具体做法如下:

  1. 使用最近 LOOKBACK_DAYS 个已完成日线构建历史窗口;
  2. 逐日计算代理价差序列;
  3. 计算历史均值和标准差;
  4. 将最新一个已完成日线的价差代入,得到当前 Z-score;
  5. 若标准差过小,则跳过本次信号,避免极端放大。

下单手数换算

交易执行时,以原油腿作为基准手数:

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 万元

回测口径说明

本次回测采用阶段性展示窗口,主要用于呈现:

  1. 国内裂解利润代理价差的构建方式;
  2. 原油桶口径向燃料油吨口径的映射逻辑;
  3. 三腿组合在 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()