Estratégia de Pares por Cointegração
Esta estratégia opera dois ativos que compartilham um relacionamento de cointegração de longo prazo. Calculando o resíduo entre o primeiro ativo e um segundo ativo ajustado por beta, procura desvios que historicamente revertem ao equilíbrio.
Os testes indicam um retorno anual médio de aproximadamente 103%. Funciona melhor no mercado de ações.
Uma posição comprada compra o primeiro ativo e vende o segundo quando o z-score residual cai abaixo de -EntryThreshold. Uma posição vendida vende o primeiro e compra o segundo quando o z-score sobe acima do limiar. As posições são fechadas assim que o spread se normaliza em direção a zero.
O trading de pares por cointegração é adequado para arbitragistas estatísticos confortáveis em gerenciar dois instrumentos simultaneamente. O stop-loss integrado protege contra movimentos extremos se o relacionamento temporariamente se romper.
Detalhes
- Critérios de entrada:
- Comprado: Z-Score Residual < -EntryThreshold
- Vendido: Z-Score Residual > EntryThreshold
- Comprado/Vendido: Ambos os lados.
- Critérios de saída:
- Comprado: Sair quando |Z-Score| < ExitThreshold
- Vendido: Sair quando |Z-Score| < ExitThreshold
- Stops: Sim, stop-loss percentual.
- Valores padrão:
Period= 20EntryThreshold= 2ExitThreshold= 0.5Beta= 1StopLossPercent= 2CandleType= TimeSpan.FromMinutes(5)Asset2— obrigatório, sem valor padrão O resíduo é o fechamento do Security da estratégia menos Beta vezes o fechamento de Asset2 em candles do mesmo horário, e o seu z-score usa a média e o desvio padrão dos últimos Period resíduos. 0.5 é o nível de saída padrão citado nas regras. A primeira perna negocia Volume e Asset2 negocia Beta vezes Volume; um sinal oposto inverte as duas pernas. O stop fecha as duas pernas quando o resíduo se move contra o par em StopLossPercent do seu valor de entrada, verificado no fechamento dos candles; 0 o desativa.
- Filtros:
- Categoria: Arbitragem
- Direção: Ambos
- Indicadores: Cointegração
- Stops: Sim
- Complexidade: Intermediário
- Período: Intradiário
- Sazonalidade: Não
- Redes neurais: Não
- Divergência: Sim
- Nível de risco: Médio
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()