Pairs Trading Stocks Strategy
This simplified pairs trading strategy operates on multiple stock pairs. For each pair the price ratio is tracked over a rolling window and its z-score is computed. When the z-score exceeds an entry threshold a long/short trade is opened; positions are closed when the z-score reverts.
The algorithm supports trading multiple independent pairs simultaneously.
Details
- Universe: list of stock pairs.
- Signal: z-score of price ratio crossing
EntryZ. - Exit: close when z-score reaches
ExitZ. - Data: daily candles with 60-day lookback by default.
- Risk control: trades skipped when order value below
MinTradeUsd.
using System;
using System.Collections.Generic;
using Ecng.Common;
using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Configuration;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Mean-reversion pairs trading strategy for stocks that trades the primary instrument against a benchmark stock using the ratio z-score.
/// </summary>
public class PairsTradingStocksStrategy : Strategy
{
private readonly StrategyParam<string> _security2Id;
private readonly StrategyParam<int> _windowLength;
private readonly StrategyParam<decimal> _entryThreshold;
private readonly StrategyParam<decimal> _exitThreshold;
private readonly StrategyParam<int> _cooldownBars;
private readonly StrategyParam<decimal> _stopLoss;
private readonly StrategyParam<DataType> _candleType;
private Security _benchmark = null!;
private SimpleMovingAverage _ratioAverage = null!;
private StandardDeviation _ratioDeviation = null!;
private decimal _latestPrimaryClose;
private decimal _latestBenchmarkClose;
private bool _primaryUpdated;
private bool _benchmarkUpdated;
private int _cooldownRemaining;
/// <summary>
/// Benchmark stock identifier.
/// </summary>
public string Security2Id
{
get => _security2Id.Value;
set => _security2Id.Value = value;
}
/// <summary>
/// Lookback period used to estimate the ratio mean and deviation.
/// </summary>
public int WindowLength
{
get => _windowLength.Value;
set => _windowLength.Value = value;
}
/// <summary>
/// Z-score threshold required to open a paired position.
/// </summary>
public decimal EntryThreshold
{
get => _entryThreshold.Value;
set => _entryThreshold.Value = value;
}
/// <summary>
/// Z-score threshold required to close the paired position.
/// </summary>
public decimal ExitThreshold
{
get => _exitThreshold.Value;
set => _exitThreshold.Value = value;
}
/// <summary>
/// Closed candles to wait before another position change.
/// </summary>
public int CooldownBars
{
get => _cooldownBars.Value;
set => _cooldownBars.Value = value;
}
/// <summary>
/// Stop loss percentage.
/// </summary>
public decimal StopLoss
{
get => _stopLoss.Value;
set => _stopLoss.Value = value;
}
/// <summary>
/// Candle type used for calculations.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
public PairsTradingStocksStrategy()
{
_security2Id = Param(nameof(Security2Id), Paths.HistoryDefaultSecurity2)
.SetDisplay("Benchmark Security Id", "Identifier of the benchmark stock", "General");
_windowLength = Param(nameof(WindowLength), 20)
.SetRange(5, 120)
.SetDisplay("Window Length", "Lookback period used to estimate the ratio mean and deviation", "Indicators");
_entryThreshold = Param(nameof(EntryThreshold), 1.2m)
.SetRange(0.2m, 5m)
.SetDisplay("Entry Threshold", "Z-score threshold required to open a paired position", "Signals");
_exitThreshold = Param(nameof(ExitThreshold), 0.3m)
.SetRange(0m, 2m)
.SetDisplay("Exit Threshold", "Z-score threshold required to close the paired position", "Signals");
_cooldownBars = Param(nameof(CooldownBars), 6)
.SetRange(0, 120)
.SetDisplay("Cooldown Bars", "Closed candles to wait before another position change", "Risk");
_stopLoss = Param(nameof(StopLoss), 3m)
.SetRange(0.5m, 10m)
.SetDisplay("Stop Loss %", "Stop loss percentage", "Risk");
_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
.SetDisplay("Candle Type", "Time frame for candles", "General");
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
if (Security != null)
yield return (Security, CandleType);
if (!Security2Id.IsEmpty())
yield return (new Security { Id = Security2Id }, CandleType);
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_benchmark = null!;
_ratioAverage = null!;
_ratioDeviation = null!;
_latestPrimaryClose = 0m;
_latestBenchmarkClose = 0m;
_primaryUpdated = false;
_benchmarkUpdated = false;
_cooldownRemaining = 0;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
if (Security == null)
throw new InvalidOperationException("Primary security is not specified.");
if (Security2Id.IsEmpty())
throw new InvalidOperationException("Benchmark security identifier is not specified.");
_benchmark = this.LookupById(Security2Id) ?? new Security { Id = Security2Id };
_ratioAverage = new SimpleMovingAverage { Length = WindowLength };
_ratioDeviation = new StandardDeviation { Length = WindowLength };
var primarySubscription = SubscribeCandles(CandleType, security: Security);
var benchmarkSubscription = SubscribeCandles(CandleType, security: _benchmark);
primarySubscription
.Bind(ProcessPrimaryCandle)
.Start();
benchmarkSubscription
.Bind(ProcessBenchmarkCandle)
.Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, primarySubscription);
DrawCandles(area, benchmarkSubscription);
DrawOwnTrades(area);
}
StartProtection(
new Unit(2, UnitTypes.Percent),
new Unit(StopLoss, UnitTypes.Percent));
}
private void ProcessPrimaryCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
_latestPrimaryClose = candle.ClosePrice;
_primaryUpdated = true;
TryProcessPair(candle.OpenTime);
}
private void ProcessBenchmarkCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
_latestBenchmarkClose = candle.ClosePrice;
_benchmarkUpdated = true;
TryProcessPair(candle.OpenTime);
}
private void TryProcessPair(DateTime time)
{
if (!_primaryUpdated || !_benchmarkUpdated || _latestPrimaryClose <= 0m || _latestBenchmarkClose <= 0m)
return;
_primaryUpdated = false;
_benchmarkUpdated = false;
var ratio = _latestPrimaryClose / _latestBenchmarkClose;
var mean = _ratioAverage.Process(ratio, time, true).ToDecimal();
var deviation = _ratioDeviation.Process(ratio, time, true).ToDecimal();
if (!_ratioAverage.IsFormed || !_ratioDeviation.IsFormed || deviation <= 0m)
return;
if (!IsFormedAndOnlineAndAllowTrading())
return;
if (_cooldownRemaining > 0)
_cooldownRemaining--;
var zScore = (ratio - mean) / deviation;
if (Math.Abs(zScore) <= ExitThreshold)
{
FlattenPair();
return;
}
if (_cooldownRemaining > 0)
return;
if (zScore >= EntryThreshold)
{
SetPairPosition(-1m);
_cooldownRemaining = CooldownBars;
}
else if (zScore <= -EntryThreshold)
{
SetPairPosition(1m);
_cooldownRemaining = CooldownBars;
}
}
private void FlattenPair()
{
var primaryPosition = GetPositionValue(Security, Portfolio) ?? 0m;
var benchmarkPosition = GetPositionValue(_benchmark, Portfolio) ?? 0m;
if (primaryPosition > 0m)
SellMarket(primaryPosition);
else if (primaryPosition < 0m)
BuyMarket(Math.Abs(primaryPosition));
if (benchmarkPosition > 0m)
RegisterOrder(new Order
{
Security = _benchmark,
Portfolio = Portfolio,
Side = Sides.Sell,
Volume = benchmarkPosition,
Type = OrderTypes.Market,
Comment = "PairsExit"
});
else if (benchmarkPosition < 0m)
RegisterOrder(new Order
{
Security = _benchmark,
Portfolio = Portfolio,
Side = Sides.Buy,
Volume = Math.Abs(benchmarkPosition),
Type = OrderTypes.Market,
Comment = "PairsExit"
});
}
private void SetPairPosition(decimal primaryDirection)
{
var primaryPosition = GetPositionValue(Security, Portfolio) ?? 0m;
var benchmarkPosition = GetPositionValue(_benchmark, Portfolio) ?? 0m;
var targetPrimary = primaryDirection;
var targetBenchmark = -primaryDirection;
MoveSecurity(Security, primaryPosition, targetPrimary);
MoveSecurity(_benchmark, benchmarkPosition, targetBenchmark);
}
private void MoveSecurity(Security security, decimal currentPosition, decimal targetPosition)
{
var diff = targetPosition - currentPosition;
if (diff == 0m)
return;
RegisterOrder(new Order
{
Security = security,
Portfolio = Portfolio,
Side = diff > 0m ? Sides.Buy : Sides.Sell,
Volume = Math.Abs(diff),
Type = OrderTypes.Market,
Comment = "Pairs"
});
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.BusinessEntities")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan, Math
from StockSharp.Messages import DataType, CandleStates, Unit, UnitTypes, Sides, OrderTypes
from StockSharp.Algo.Indicators import SimpleMovingAverage, StandardDeviation
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Security, Order
from indicator_extensions import *
class pairs_trading_stocks_strategy(Strategy):
"""Mean-reversion pairs trading strategy for stocks that trades the primary instrument against a benchmark stock using the ratio z-score."""
def __init__(self):
super(pairs_trading_stocks_strategy, self).__init__()
self._security2_id = self.Param("Security2Id", "TONUSDT@BNBFT") \
.SetDisplay("Benchmark Security Id", "Identifier of the benchmark stock", "General")
self._window_length = self.Param("WindowLength", 20) \
.SetRange(5, 120) \
.SetDisplay("Window Length", "Lookback period used to estimate the ratio mean and deviation", "Indicators")
self._entry_threshold = self.Param("EntryThreshold", 1.2) \
.SetRange(0.2, 5.0) \
.SetDisplay("Entry Threshold", "Z-score threshold required to open a paired position", "Signals")
self._exit_threshold = self.Param("ExitThreshold", 0.3) \
.SetRange(0.0, 2.0) \
.SetDisplay("Exit Threshold", "Z-score threshold required to close the paired position", "Signals")
self._cooldown_bars = self.Param("CooldownBars", 6) \
.SetRange(0, 120) \
.SetDisplay("Cooldown Bars", "Closed candles to wait before another position change", "Risk")
self._stop_loss = self.Param("StopLoss", 3.0) \
.SetRange(0.5, 10.0) \
.SetDisplay("Stop Loss %", "Stop loss percentage", "Risk")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromHours(4))) \
.SetDisplay("Candle Type", "Time frame for candles", "General")
self._benchmark = None
self._ratio_average = None
self._ratio_deviation = None
self._latest_primary_close = 0.0
self._latest_benchmark_close = 0.0
self._primary_updated = False
self._benchmark_updated = False
self._cooldown_remaining = 0
@property
def candle_type(self):
return self._candle_type.Value
def GetWorkingSecurities(self):
result = []
if self.Security is not None:
result.append((self.Security, self.candle_type))
sec2_id = str(self._security2_id.Value)
if sec2_id:
s = Security()
s.Id = sec2_id
result.append((s, self.candle_type))
return result
def OnReseted(self):
super(pairs_trading_stocks_strategy, self).OnReseted()
self._benchmark = None
self._ratio_average = None
self._ratio_deviation = None
self._latest_primary_close = 0.0
self._latest_benchmark_close = 0.0
self._primary_updated = False
self._benchmark_updated = False
self._cooldown_remaining = 0
def OnStarted2(self, time):
super(pairs_trading_stocks_strategy, self).OnStarted2(time)
sec2_id = str(self._security2_id.Value)
if not sec2_id:
raise Exception("Benchmark security identifier is not specified.")
s = Security()
s.Id = sec2_id
self._benchmark = s
window_len = int(self._window_length.Value)
self._ratio_average = SimpleMovingAverage()
self._ratio_average.Length = window_len
self._ratio_deviation = StandardDeviation()
self._ratio_deviation.Length = window_len
primary_subscription = self.SubscribeCandles(self.candle_type, True, self.Security)
benchmark_subscription = self.SubscribeCandles(self.candle_type, True, self._benchmark)
primary_subscription.Bind(self.ProcessPrimaryCandle).Start()
benchmark_subscription.Bind(self.ProcessBenchmarkCandle).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, primary_subscription)
self.DrawCandles(area, benchmark_subscription)
self.DrawOwnTrades(area)
self.StartProtection(
Unit(2, UnitTypes.Percent),
Unit(float(self._stop_loss.Value), UnitTypes.Percent)
)
def ProcessPrimaryCandle(self, candle):
if candle.State != CandleStates.Finished:
return
self._latest_primary_close = float(candle.ClosePrice)
self._primary_updated = True
self.TryProcessPair(candle.OpenTime)
def ProcessBenchmarkCandle(self, candle):
if candle.State != CandleStates.Finished:
return
self._latest_benchmark_close = float(candle.ClosePrice)
self._benchmark_updated = True
self.TryProcessPair(candle.OpenTime)
def TryProcessPair(self, time):
if not self._primary_updated or not self._benchmark_updated or self._latest_primary_close <= 0 or self._latest_benchmark_close <= 0:
return
self._primary_updated = False
self._benchmark_updated = False
ratio = self._latest_primary_close / self._latest_benchmark_close
mean = float(process_float(self._ratio_average, ratio, time, True))
deviation = float(process_float(self._ratio_deviation, ratio, time, True))
if not self._ratio_average.IsFormed or not self._ratio_deviation.IsFormed or deviation <= 0:
return
if not self.IsFormedAndOnlineAndAllowTrading():
return
if self._cooldown_remaining > 0:
self._cooldown_remaining -= 1
z_score = (ratio - mean) / deviation
entry_thresh = float(self._entry_threshold.Value)
exit_thresh = float(self._exit_threshold.Value)
cooldown = int(self._cooldown_bars.Value)
if abs(z_score) <= exit_thresh:
self.FlattenPair()
return
if self._cooldown_remaining > 0:
return
if z_score >= entry_thresh:
self.SetPairPosition(-1.0)
self._cooldown_remaining = cooldown
elif z_score <= -entry_thresh:
self.SetPairPosition(1.0)
self._cooldown_remaining = cooldown
def FlattenPair(self):
primary_pos_val = self.GetPositionValue(self.Security, self.Portfolio)
primary_position = float(primary_pos_val) if primary_pos_val is not None else 0.0
benchmark_pos_val = self.GetPositionValue(self._benchmark, self.Portfolio)
benchmark_position = float(benchmark_pos_val) if benchmark_pos_val is not None else 0.0
if primary_position > 0:
self.SellMarket(primary_position)
elif primary_position < 0:
self.BuyMarket(Math.Abs(primary_position))
if benchmark_position > 0:
order = Order()
order.Security = self._benchmark
order.Portfolio = self.Portfolio
order.Side = Sides.Sell
order.Volume = benchmark_position
order.Type = OrderTypes.Market
order.Comment = "PairsExit"
self.RegisterOrder(order)
elif benchmark_position < 0:
order = Order()
order.Security = self._benchmark
order.Portfolio = self.Portfolio
order.Side = Sides.Buy
order.Volume = Math.Abs(benchmark_position)
order.Type = OrderTypes.Market
order.Comment = "PairsExit"
self.RegisterOrder(order)
def SetPairPosition(self, primary_direction):
primary_pos_val = self.GetPositionValue(self.Security, self.Portfolio)
primary_position = float(primary_pos_val) if primary_pos_val is not None else 0.0
benchmark_pos_val = self.GetPositionValue(self._benchmark, self.Portfolio)
benchmark_position = float(benchmark_pos_val) if benchmark_pos_val is not None else 0.0
target_primary = primary_direction
target_benchmark = -primary_direction
self.MoveSecurity(self.Security, primary_position, target_primary)
self.MoveSecurity(self._benchmark, benchmark_position, target_benchmark)
def MoveSecurity(self, security, current_position, target_position):
diff = target_position - current_position
if diff == 0:
return
order = Order()
order.Security = security
order.Portfolio = self.Portfolio
order.Side = Sides.Buy if diff > 0 else Sides.Sell
order.Volume = abs(diff)
order.Type = OrderTypes.Market
order.Comment = "Pairs"
self.RegisterOrder(order)
def CreateClone(self):
return pairs_trading_stocks_strategy()