Cointegration Pairs 策略
该策略交易长期协整关系的两只资产。通过计算第一只资产与经beta调整的第二只资产之间的残差z值,寻找会回归均衡的偏离。
测试表明年均收益约为 103%,该策略在股票市场表现最佳。
当残差z值低于-EntryThreshold时买入第一只、卖出第二只;当z值高于阈值时做相反操作。价差回到零附近时平仓。
此策略适合能够同时管理两只工具的统计套利者,内置止损在关系短暂失衡时保护资金。
细节
- 入场条件:
- 多头:
Z-Score < -EntryThreshold - 空头:
Z-Score > EntryThreshold
- 多头:
- 多/空: 双向
- 离场条件:
- 多头:
|Z-Score| < ExitThreshold时平仓 - 空头:
|Z-Score| < ExitThreshold时平仓
- 多头:
- 止损: 百分比止损
- 默认值:
Period= 20EntryThreshold= 2ExitThreshold= 0.5Beta= 1StopLossPercent= 2CandleType= TimeSpan.FromMinutes(5)Asset2— 必填,无默认值 残差为策略主品种(Security)收盘价减去Beta倍Asset2在同一时间K线上的收盘价,其z-score基于最近Period个残差的均值和标准差。0.5是规则中离场水平的默认值。第一条腿交易Volume数量,Asset2交易Beta倍Volume;相反信号会同时反转两条腿。当残差朝不利方向移动入场残差的StopLossPercent百分比时止损平掉两条腿,按K线收盘检查;设为0则关闭。
- 过滤器:
- 类别: Arbitrage
- 方向: 双向
- 指标: Cointegration
- 止损: 是
- 复杂度: 中等
- 时间框架: 日内
- 季节性: 否
- 神经网络: 否
- 背离: 是
- 风险等级: 中等
using System;
using System.Collections.Generic;
using System.Linq;
using Ecng.Common;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Cointegration pairs trading strategy.
/// The residual is the first instrument's close minus Beta times Asset2's on candles of the same time, and its z-score is measured against
/// the mean and standard deviation of the last Period residuals. A z-score below minus EntryThreshold buys the first instrument and sells
/// Beta times as much of Asset2, one above EntryThreshold does the opposite, reversing an opposite pair. Both legs close once the z-score
/// is back within ExitThreshold of zero, or once the residual moves StopLossPercent of its entry value against the pair.
/// </summary>
public class CointegrationPairsStrategy : Strategy
{
private readonly StrategyParam<int> _period;
private readonly StrategyParam<decimal> _entryThreshold;
private readonly StrategyParam<decimal> _exitThreshold;
private readonly StrategyParam<decimal> _beta;
private readonly StrategyParam<Security> _asset2;
private readonly StrategyParam<decimal> _stopLossPercent;
private readonly StrategyParam<DataType> _candleType;
private readonly Dictionary<DateTime, decimal> _firstCloses = [];
private readonly Dictionary<DateTime, decimal> _secondCloses = [];
private readonly Queue<decimal> _residuals = [];
// 1 while long the pair, -1 while short it, 0 while flat.
private int _side;
private decimal _entryResidual;
/// <summary>
/// Number of residuals the mean and standard deviation are measured over.
/// </summary>
public int Period
{
get => _period.Value;
set => _period.Value = value;
}
/// <summary>
/// Z-score distance from zero that opens a pair.
/// </summary>
public decimal EntryThreshold
{
get => _entryThreshold.Value;
set => _entryThreshold.Value = value;
}
/// <summary>
/// Z-score distance from zero within which the pair closes.
/// </summary>
public decimal ExitThreshold
{
get => _exitThreshold.Value;
set => _exitThreshold.Value = value;
}
/// <summary>
/// Hedge ratio of Asset2 to the first instrument.
/// </summary>
public decimal Beta
{
get => _beta.Value;
set => _beta.Value = value;
}
/// <summary>
/// Second asset of the pair.
/// </summary>
public Security Asset2
{
get => _asset2.Value;
set => _asset2.Value = value;
}
/// <summary>
/// Adverse residual move, in percent of the entry residual, that closes the pair.
/// </summary>
public decimal StopLossPercent
{
get => _stopLossPercent.Value;
set => _stopLossPercent.Value = value;
}
/// <summary>
/// Candle type.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Constructor.
/// </summary>
public CointegrationPairsStrategy()
{
_period = Param(nameof(Period), 20)
.SetGreaterThanZero()
.SetDisplay("Period", "Residuals the mean and standard deviation are measured over", "Parameters");
_entryThreshold = Param(nameof(EntryThreshold), 2m)
.SetGreaterThanZero()
.SetDisplay("Entry Threshold", "Z-score distance from zero that opens a pair", "Parameters");
_exitThreshold = Param(nameof(ExitThreshold), 0.5m)
.SetNotNegative()
.SetDisplay("Exit Threshold", "Z-score distance from zero within which the pair closes", "Parameters");
_beta = Param(nameof(Beta), 1m)
.SetGreaterThanZero()
.SetDisplay("Beta", "Hedge ratio of Asset2 to the first instrument", "Parameters");
_asset2 = Param<Security>(nameof(Asset2))
.SetDisplay("Asset 2", "Second asset of the pair", "Parameters")
.SetRequired();
_stopLossPercent = Param(nameof(StopLossPercent), 2m)
.SetNotNegative()
.SetDisplay("Stop Loss %", "Adverse residual move in percent of the entry residual", "Risk Management");
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
.SetDisplay("Candle Type", "Type of candles to use", "General");
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
return [(Security, CandleType), (Asset2, CandleType)];
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
ResetState();
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
if (Asset2 == null)
throw new InvalidOperationException("Second asset is not specified.");
ResetState();
var firstSubscription = SubscribeCandles(CandleType);
firstSubscription.Bind(candle => ProcessCandle(candle, _firstCloses)).Start();
var secondSubscription = SubscribeCandles(CandleType, security: Asset2);
secondSubscription.Bind(candle => ProcessCandle(candle, _secondCloses)).Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, firstSubscription);
DrawOwnTrades(area);
}
}
private void ResetState()
{
_firstCloses.Clear();
_secondCloses.Clear();
_residuals.Clear();
_side = 0;
_entryResidual = 0;
}
private void ProcessCandle(ICandleMessage candle, Dictionary<DateTime, decimal> closes)
{
if (candle.State != CandleStates.Finished)
return;
closes[candle.OpenTime] = candle.ClosePrice;
// The residual needs both instruments' candles of the same time, whichever arrives last.
if (!_firstCloses.TryGetValue(candle.OpenTime, out var first) || !_secondCloses.TryGetValue(candle.OpenTime, out var second))
return;
foreach (var stale in _firstCloses.Keys.Where(t => t <= candle.OpenTime).ToArray())
_firstCloses.Remove(stale);
foreach (var stale in _secondCloses.Keys.Where(t => t <= candle.OpenTime).ToArray())
_secondCloses.Remove(stale);
var residual = first - Beta * second;
_residuals.Enqueue(residual);
if (_residuals.Count > Period)
_residuals.Dequeue();
if (_residuals.Count < Period)
return;
var mean = _residuals.Average();
var deviation = (decimal)Math.Sqrt((double)_residuals.Average(r => (r - mean) * (r - mean)));
if (deviation == 0)
return;
var zScore = (residual - mean) / deviation;
if (!IsFormedAndOnlineAndAllowTrading())
return;
var stopDistance = Math.Abs(_entryResidual) * StopLossPercent / 100;
if (zScore < -EntryThreshold && _side <= 0)
{
MoveLegs(1);
_entryResidual = residual;
}
else if (zScore > EntryThreshold && _side >= 0)
{
MoveLegs(-1);
_entryResidual = residual;
}
else if (_side > 0 && (Math.Abs(zScore) < ExitThreshold || (StopLossPercent > 0 && residual <= _entryResidual - stopDistance)))
{
MoveLegs(0);
}
else if (_side < 0 && (Math.Abs(zScore) < ExitThreshold || (StopLossPercent > 0 && residual >= _entryResidual + stopDistance)))
{
MoveLegs(0);
}
}
private void MoveLegs(int side)
{
_side = side;
// Long the pair holds Volume of the first instrument and is short Beta times as much of Asset2.
var firstChange = side * Volume - Position;
if (firstChange > 0)
BuyMarket(firstChange);
else if (firstChange < 0)
SellMarket(-firstChange);
var secondChange = -side * Volume * Beta - (GetPositionValue(Asset2, Portfolio) ?? 0m);
if (secondChange > 0)
BuyMarket(secondChange, Asset2);
else if (secondChange < 0)
SellMarket(-secondChange, Asset2);
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Strategies")
clr.AddReference("StockSharp.BusinessEntities")
from System import TimeSpan, Decimal, Math
from StockSharp.Messages import DataType, CandleStates
from StockSharp.BusinessEntities import Security
from StockSharp.Algo.Strategies import Strategy
class cointegration_pairs_strategy(Strategy):
"""
Cointegration pairs trading strategy.
The residual is the first instrument's close minus Beta times Asset2's on candles of the same time, and its z-score is measured against
the mean and standard deviation of the last Period residuals. A z-score below minus EntryThreshold buys the first instrument and sells
Beta times as much of Asset2, one above EntryThreshold does the opposite, reversing an opposite pair. Both legs close once the z-score
is back within ExitThreshold of zero, or once the residual moves StopLossPercent of its entry value against the pair.
"""
def __init__(self):
super(cointegration_pairs_strategy, self).__init__()
self._period = self.Param("Period", 20).SetGreaterThanZero().SetDisplay("Period", "Residuals the mean and standard deviation are measured over", "Parameters")
self._entry_threshold = self.Param("EntryThreshold", 2.0).SetGreaterThanZero().SetDisplay("Entry Threshold", "Z-score distance from zero that opens a pair", "Parameters")
self._exit_threshold = self.Param("ExitThreshold", 0.5).SetNotNegative().SetDisplay("Exit Threshold", "Z-score distance from zero within which the pair closes", "Parameters")
self._beta = self.Param("Beta", 1.0).SetGreaterThanZero().SetDisplay("Beta", "Hedge ratio of Asset2 to the first instrument", "Parameters")
self._asset2 = self.Param[Security]("Asset2", None).SetDisplay("Asset 2", "Second asset of the pair", "Parameters").SetRequired()
self._stop_loss_percent = self.Param("StopLossPercent", 2.0).SetNotNegative().SetDisplay("Stop Loss %", "Adverse residual move in percent of the entry residual", "Risk Management")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(5))).SetDisplay("Candle Type", "Type of candles to use", "General")
self._reset_state()
@property
def candle_type(self):
return self._candle_type.Value
@property
def asset2(self):
return self._asset2.Value
@asset2.setter
def asset2(self, value):
self._asset2.Value = value
def _reset_state(self):
self._first_closes = {}
self._second_closes = {}
self._residuals = []
# 1 while long the pair, -1 while short it, 0 while flat.
self._side = 0
self._entry_residual = Decimal(0)
def GetWorkingSecurities(self):
return [(self.Security, self.candle_type), (self.asset2, self.candle_type)]
def OnReseted(self):
super(cointegration_pairs_strategy, self).OnReseted()
self._reset_state()
def OnStarted2(self, time):
super(cointegration_pairs_strategy, self).OnStarted2(time)
if self.asset2 is None:
raise Exception("Second asset is not specified.")
self._reset_state()
first_subscription = self.SubscribeCandles(self.candle_type)
first_subscription.Bind(self._process_first_candle).Start()
second_subscription = self.SubscribeCandles(self.candle_type, security=self.asset2)
second_subscription.Bind(self._process_second_candle).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, first_subscription)
self.DrawOwnTrades(area)
def _process_first_candle(self, candle):
self._process_candle(candle, self._first_closes)
def _process_second_candle(self, candle):
self._process_candle(candle, self._second_closes)
def _process_candle(self, candle, closes):
if candle.State != CandleStates.Finished:
return
time = candle.OpenTime
closes[time] = candle.ClosePrice
# The residual needs both instruments' candles of the same time, whichever arrives last.
if time not in self._first_closes or time not in self._second_closes:
return
first = self._first_closes[time]
second = self._second_closes[time]
for stale in [t for t in self._first_closes if t <= time]:
del self._first_closes[stale]
for stale in [t for t in self._second_closes if t <= time]:
del self._second_closes[stale]
residual = first - Decimal(self._beta.Value) * second
period = self._period.Value
self._residuals.append(residual)
if len(self._residuals) > period:
self._residuals.pop(0)
if len(self._residuals) < period:
return
total = Decimal(0)
for value in self._residuals:
total += value
mean = total / Decimal(period)
squares = Decimal(0)
for value in self._residuals:
squares += (value - mean) * (value - mean)
deviation = Decimal(Math.Sqrt(Decimal.ToDouble(squares / Decimal(period))))
if deviation == 0:
return
z_score = (residual - mean) / deviation
if not self.IsFormedAndOnlineAndAllowTrading():
return
entry = Decimal(self._entry_threshold.Value)
exit_level = Decimal(self._exit_threshold.Value)
stop_percent = Decimal(self._stop_loss_percent.Value)
stop_distance = abs(self._entry_residual) * stop_percent / Decimal(100)
if z_score < -entry and self._side <= 0:
self._move_legs(1)
self._entry_residual = residual
elif z_score > entry and self._side >= 0:
self._move_legs(-1)
self._entry_residual = residual
elif self._side > 0 and (abs(z_score) < exit_level or (stop_percent > 0 and residual <= self._entry_residual - stop_distance)):
self._move_legs(0)
elif self._side < 0 and (abs(z_score) < exit_level or (stop_percent > 0 and residual >= self._entry_residual + stop_distance)):
self._move_legs(0)
def _move_legs(self, side):
self._side = side
# Long the pair holds Volume of the first instrument and is short Beta times as much of Asset2.
first_change = Decimal(side) * self.Volume - self.Position
if first_change > 0:
self.BuyMarket(first_change)
elif first_change < 0:
self.SellMarket(-first_change)
second_position = self.GetPositionValue(self.asset2, self.Portfolio)
if second_position is None:
second_position = Decimal(0)
second_change = Decimal(-side) * self.Volume * Decimal(self._beta.Value) - second_position
if second_change > 0:
self.BuyMarket(second_change, self.asset2)
elif second_change < 0:
self.SellMarket(-second_change, self.asset2)
def CreateClone(self):
return cointegration_pairs_strategy()