第二条换成豆粕:贴水才做多,升水空仓。编号 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