Home >  > 年化17%套利策略

年化17%套利策略

第二条换成豆粕:贴水才做多,升水空仓。编号 FQ-002。
全样本年化大约 17%,回撤仍有 25% 上下,2022 一年扛了大约一半利润。

一、策略介绍

(一)、为什么是“贴水做多”?

很多刚接触期货的朋友会问:期货不是零和博弈吗?凭什么某个策略能赚钱?答案往往藏在市场结构里,而不是预测涨跌。

国际学术界有一个很干净的概念叫 Carry(展期收益)

简单说,你持有某个资产,除了价格涨跌带来的收益,还有一块“什么都不做也能拿到的收益”。

对于商品期货,这块收益来自期限结构——也就是不同到期月份合约之间的价差。

想象你去超市买大米:

如果今天大米短缺,现货很贵,但三个月后新米上市,远期价格便宜。

这时候,现货价 > 远期价,期货曲线向下倾斜,叫做贴水(Backwardation)。反过来,如果库存充足,现货便宜,远期贵,曲线向上倾斜,叫升水(Contango)。

为什么贴水对多头有利?

因为如果你买入近月合约并持有,随着时间推移,合约价格会慢慢向现货价格靠拢(收敛),即使现货价格不动,你也能赚到“贴水收敛”的钱。

这就像你低价买入了一张远期提货券,随着时间的推移,提货券的价值会向现货价格回归。库存越紧张,贴水越深,这个“收敛收益”就越大。

著名论文 Koijen, Moskowitz, Pedersen, Vrugt (2018) 在《Journal of Financial Economics》上系统研究了这个现象。

他们发现,商品期货的预期收益可以拆成两部分:价格不动时能拿到的收益(carry)和预期价格变动。而 carry 的正负,几乎就是未来收益的方向标。他们用近月和次近月合约的价差来近似 carry,构建了多空组合,取得了不错的长期表现。

另一篇经典 Gorton, Hayashi, Rouwenhorst (2013) 更进一步解释了为什么贴水有溢价:库存低的时候,现货紧张,便利收益(持有实物商品的好处)高,近月价格相对远月更贵,这种状态下的风险溢价更高。

所以,基差本身就是库存状态的代理变量。我们不必每天去查仓库里有多少吨豆粕,只需要看期货曲线的斜率。

但要注意,论文里的高收益来自几十个品种的多空组合、波动率调整和机构级执行。对于只有 1 万块、只能做 1 手豆粕的小账户,我们能复现的只是最核心的一句话:本品种贴水时,持有多头更划算

在中国市场,这套逻辑需要调整。Fan & Zhang (2020, 2021) 的两篇论文指出,国内商品期货的期限结构和动量仍然是显著的收益来源,但套保压力、偏度、波动率溢价这些在国外显著的因子,在国内被制度扭曲了。更关键的是,国内期货的流动性分布和国外不同:近月合约常常没人交易,主力合约往往不是最近到期的那个。所以,我们不能简单地用“最近两个合约”算价差,而要用“主力合约”和“更远但仍有流动性的合约”。

于是,我的策略规则变得非常简单:

每天收盘后,用主力合约和次远月流动合约的收盘价计算年化展期收益率。如果这个值是正的(贴水),第二天开盘就做多 1 手;如果是负的(升水)或者算不出来,就空仓。

就这么一句话,没有均线,没有预测涨跌,只跟随期限结构。

(二)策略细节:为什么是豆粕,为什么只做多?

执行外壳我沿用了第一条策略(FQ-001 玉米动量)的框架:日频,T-1 收盘算信号,T 开盘成交,固定 1 手,浮亏超过权益 8% 止损并冷却 5 天,主力换月先平旧开新。这样保持两条策略的操作习惯一致。

品种选择豆粕,不是因为历史收益最高,而是因为它适合这个账户的约束:

保证金低:1 手豆粕保证金大约 1 万以内,1 万本金刚好能放下一手,还留有安全垫。
与玉米低相关:豆粕是饲料蛋白源,玉米是能量饲料,两者基本面逻辑不同,不会和第一条策略高度重叠。
库存故事清晰:豆粕的库存受进口大豆到港、压榨利润、养殖需求影响,贴水升水变化有实际经济含义,不像某些化工品那样容易被资金操纵。

论文里那些真正贡献夏普的东西,在 1 万账户上几乎都不能做:

横截面多空:多最贴水的品种、空最升水的品种。1 手做不了,会退化成“追最强品种”,回撤巨大。
日历价差:同时买近月卖远月,需要两腿保证金,几乎等于两手。
做空升水:这是 carry 的另一条腿,但单品种玉米空头已经在 FQ-001 的测试中亏过,而且做空升水在库存宽松时可能被逼仓风险放大。这次先只做多,升水就空仓。
波动率调整仓位:论文按波动率加减仓,但 1 手是整数,无法按比例微调。固定 1 手,不加仓。

所以 FQ-002 的成功标准也不是“500%的收益来吧!”,而是:

规则能稳定执行、成本后收益不是噪声、和 FQ-001 的日收益相关性不要接近 1。

相关性低,才说明第二条有分散意义。

(三)本地回测:先在自己的电脑上跑一遍

我先在本地用 Python + AI复现同样的规则。

数据源是 AkShare 的新浪豆粕合约日线,从 2018 年 5 月到 2026 年 9 月,大约 60 张合约。费用假设:手续费万分之 1 每边,滑点 1 跳,保证金 12%。信号在 T-1 收盘计算,T 开盘成交。

为什么要本地跑?因为本地引擎能让我完全控制每一个假设,快速迭代,而且不依赖平台。论文里的假设在国内微观结构上会破,我需要把适配写进规则里,而不是事后解释。

豆粕上"期限结构方向"(carry)是真实的 alpha 来源,"期限结构统计偏离的回归"不是。

二、成果

我自己的回测结果


三、代码

# FQ-002-v2 豆粕展期收益(升水即平)
# 粘贴到聚宽。不要对 M9999.XDCE 下单。


def initialize(context):
    set_option('use_real_price', True)
    kind = ''
    rp = getattr(context, 'run_params', None)
    if rp is not None:
        kind = str(getattr(rp, 'type', '') or '')
    if 'backtest' in kind.lower():
        set_option('avoid_future_data', True)
    set_subportfolios([SubPortfolioConfig(cash=10000, type='futures')])
    set_benchmark('M9999.XDCE')
    set_order_cost(
        OrderCost(
            open_commission=0.0001,
            close_commission=0.0001,
            close_today_commission=0.0001,
            min_commission=0,
        ),
        type='futures',
    )
    set_slippage(StepRelatedSlippage(1), type='futures')

    g.underlying = 'M'
    g.ref = 'M9999.XDCE'
    g.hold_bars = 20
    g.flatten_on_contango = True
    g.lots = 1
    g.stop_frac = 0.08
    g.cooldown_bars = 5
    g.cooldown_left = 0
    g.bars_held = 0
    g.held_contract = None
    g.sig_near = None
    g.sig_far = None

    run_daily(rebalance, time='09:05', reference_security=g.ref)


def process_initialize(context):
    pass


def _amount(positions, contract):
    if contract in positions:
        return positions[contract].total_amount
    return 0


def _multiplier(contract):
    try:
        info = get_security_info(contract)
        m = getattr(info, 'contract_multiplier', None)
        if m:
            return float(m)
    except Exception:
        pass
    return 10.0


def _pnl(positions, contract):
    # 期货 UserPosition 没有 .pnl,用现价相对开仓均价 * 手数 * 合约乘数。
    if contract not in positions:
        return 0.0
    pos = positions[contract]
    amt = float(pos.total_amount or 0)
    if amt == 0:
        return 0.0
    price = float(getattr(pos, 'price', 0) or 0)
    cost = float(getattr(pos, 'avg_cost', 0) or 0)
    if price <= 0 or cost <= 0:
        return 0.0
    return (price - cost) * amt * _multiplier(contract)


def flatten(context, contract):
    if not contract:
        return
    if _amount(context.portfolio.long_positions, contract) > 0:
        order_target(contract, 0, side='long')
    if _amount(context.portfolio.short_positions, contract) > 0:
        order_target(contract, 0, side='short')


def open_long(context, contract):
    if _amount(context.portfolio.short_positions, contract) > 0:
        order_target(contract, 0, side='short')
    order_target(contract, g.lots, side='long')


def _root(contract):
    return str(contract).split('.')[0]


def _ym(contract):
    text = _root(contract)
    digits = ''.join(ch for ch in text if ch.isdigit())
    if len(digits) < 4:
        return None
    yy = int(digits[-4:-2])
    mm = int(digits[-2:])
    if mm < 1 or mm > 12:
        return None
    return (2000 + yy) * 12 + mm


def _close_of(contract):
    hist = attribute_history(
        contract,
        1,
        '1d',
        ['close'],
        skip_paused=True,
        df=True,
    )
    if hist is None or len(hist) < 1:
        return None
    px = float(hist['close'].iloc[-1])
    if px <= 0:
        return None
    return px


def _oi_of(contract):
    hist = attribute_history(
        contract,
        1,
        '1d',
        ['open_interest'],
        skip_paused=True,
        df=True,
    )
    if hist is None or len(hist) < 1:
        return 0.0
    return float(hist['open_interest'].iloc[-1])


def _next_liquid(dominant):
    near_ym = _ym(dominant)
    if near_ym is None:
        return None
    try:
        listed = get_future_contracts(g.underlying)
    except Exception:
        listed = get_future_contracts(dominant)
    if not listed:
        return None
    best = None
    best_oi = -1.0
    for item in listed:
        far_ym = _ym(item)
        if far_ym is None or far_ym <= near_ym:
            continue
        oi = _oi_of(item)
        if oi > best_oi:
            best_oi = oi
            best = item
    return best


def _ann_roll(near, far):
    n = _close_of(near)
    f = _close_of(far)
    n_ym = _ym(near)
    f_ym = _ym(far)
    if n is None or f is None or n_ym is None or f_ym is None:
        return None
    months = f_ym - n_ym
    if months <= 0:
        return None
    days = months * 30.0
    return (n - f) / f * (365.0 / days)


def rebalance(context):
    contract = get_dominant_future(g.underlying)
    if not contract:
        return

    if g.held_contract and g.held_contract != contract:
        flatten(context, g.held_contract)
        g.bars_held = 0
        g.held_contract = contract

    if g.cooldown_left > 0:
        flatten(context, contract)
        g.held_contract = contract
        g.bars_held = 0
        g.cooldown_left -= 1
        g.sig_near = contract
        g.sig_far = _next_liquid(contract)
        return

    long_amt = _amount(context.portfolio.long_positions, contract)
    pos_pnl = _pnl(context.portfolio.long_positions, contract)
    equity = context.portfolio.total_value
    if long_amt > 0 and equity > 0 and pos_pnl < -g.stop_frac * equity:
        flatten(context, contract)
        g.held_contract = contract
        g.bars_held = 0
        g.cooldown_left = g.cooldown_bars
        g.sig_near = contract
        g.sig_far = _next_liquid(contract)
        return

    in_pos = long_amt > 0
    if not g.sig_near or not g.sig_far:
        # 模拟盘第一天没有昨日缓存;用当前主力 + 次近月的 T-1 收盘,与本地 latest_fq002_signal 对齐。
        g.sig_near = contract
        g.sig_far = _next_liquid(contract)
    roll = _ann_roll(g.sig_near, g.sig_far)
    target = 1 if (roll is not None and roll > 0) else 0
    allow = (not in_pos) or g.bars_held >= g.hold_bars or (
        g.flatten_on_contango and in_pos and target == 0
    )
    if not allow:
        g.bars_held += 1
        g.held_contract = contract
        g.sig_near = contract
        g.sig_far = _next_liquid(contract)
        log.info('FQ-002-v2 hold %s roll=%s tgt=%s far=%s' % (contract, roll, target, g.sig_far))
        return

    if target == 1 and not in_pos:
        open_long(context, contract)
        g.bars_held = 1
    elif target == 0 and in_pos:
        flatten(context, contract)
        g.bars_held = 0
    elif in_pos:
        g.bars_held = 1

    g.held_contract = contract
    log.info('FQ-002-v2 %s roll=%s tgt=%s far=%s' % (contract, roll, target, g.sig_far))
    g.sig_near = contract
    g.sig_far = _next_liquid(contract)

https://zhuanlan.zhihu.com/p/2080033955001194296
https://github.com/david-quant-logs/future-quant-in/blob/main/strategies/FQ-002-carry-m/strategy.py

暧昧帖

本文暂无标签