Statistical Arbitrage Strategy
This statistical arbitrage approach trades a pair of related securities based on their relative positioning around moving averages. By comparing each asset to its own average, the strategy seeks to exploit short-term dislocations that should converge over time.
Testing indicates an average annual return of about 94%. It performs best in the stocks market.
A long position is initiated when the first asset trades below its moving average while the second asset trades above its own average. A short position occurs when the first asset is above its average and the second is below. Positions are closed when the first asset crosses back through its moving average, signalling the spread has normalized.
The method is ideal for market-neutral traders comfortable balancing exposure across two instruments. The built-in stop-loss limits drawdowns if the spread widens further instead of reverting.
Details
- Entry Criteria:
- Long: Asset1 < MA1 && Asset2 > MA2
- Short: Asset1 > MA1 && Asset2 < MA2
- Long/Short: Both sides.
- Exit Criteria:
- Long: Exit when Asset1 closes above its MA1
- Short: Exit when Asset1 closes below its MA1
- Stops: Yes, percent stop-loss on spread.
- Default Values:
LookbackPeriod= 20StopLossPercent= 2mCandleType= TimeSpan.FromMinutes(15)
- Filters:
- Category: Arbitrage
- Direction: Both
- Indicators: Moving Averages
- Stops: Yes
- Complexity: Intermediate
- Timeframe: Intraday
- Seasonality: No
- Neural networks: No
- Divergence: Yes
- Risk Level: Medium
using System;
using System.Linq;
using System.Collections.Generic;
using Ecng.Common;
using Ecng.Collections;
using Ecng.Serialization;
using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Statistical Arbitrage strategy that trades pairs of securities based on their relative mean reversion.
/// Enters when one asset is below its mean while the other is above its mean.
/// </summary>
public class StatisticalArbitrageStrategy : Strategy
{
private readonly StrategyParam<int> _lookbackPeriod;
private readonly StrategyParam<decimal> _stopLossPercent;
private readonly StrategyParam<DataType> _candleType;
private readonly StrategyParam<Security> _secondSecurity;
private SimpleMovingAverage _firstMA;
private SimpleMovingAverage _secondMA;
private decimal _lastFirstPrice;
private decimal _lastSecondPrice;
private decimal _entrySpread;
private decimal _secondMAValue;
/// <summary>
/// Period for calculating moving averages.
/// </summary>
public int LookbackPeriod
{
get => _lookbackPeriod.Value;
set => _lookbackPeriod.Value = value;
}
/// <summary>
/// Stop-loss percentage parameter.
/// </summary>
public decimal StopLossPercent
{
get => _stopLossPercent.Value;
set => _stopLossPercent.Value = value;
}
/// <summary>
/// Candle type parameter.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Second security in the pair.
/// </summary>
public Security SecondSecurity
{
get => _secondSecurity.Value;
set => _secondSecurity.Value = value;
}
/// <summary>
/// Constructor.
/// </summary>
public StatisticalArbitrageStrategy()
{
_lookbackPeriod = Param(nameof(LookbackPeriod), 20)
.SetGreaterThanZero()
.SetDisplay("Lookback Period", "Period for calculating moving averages", "Parameters")
.SetOptimize(10, 30, 5);
_stopLossPercent = Param(nameof(StopLossPercent), 2m)
.SetGreaterThanZero()
.SetDisplay("Stop-loss %", "Stop-loss as percentage of entry price", "Risk Management")
.SetOptimize(1m, 3m, 0.5m);
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(15).TimeFrame())
.SetDisplay("Candle Type", "Type of candles to use", "General");
_secondSecurity = Param<Security>(nameof(SecondSecurity))
.SetDisplay("Second Security", "Second security in the pair", "General")
.SetRequired();
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
return
[
(Security, CandleType),
(SecondSecurity, CandleType)
];
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_firstMA = null;
_secondMA = null;
_lastFirstPrice = 0;
_lastSecondPrice = 0;
_entrySpread = 0;
_secondMAValue = 0;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
if (SecondSecurity == null)
throw new InvalidOperationException("Second security is not specified.");
// Initialize indicators
_firstMA = new() { Length = LookbackPeriod };
_secondMA = new() { Length = LookbackPeriod };
// Create subscriptions for both securities
var firstSecuritySubscription = SubscribeCandles(CandleType);
var secondSecuritySubscription = SubscribeCandles(CandleType, security: SecondSecurity);
// Bind to first security candles
firstSecuritySubscription
.Bind(_firstMA, ProcessFirstSecurityCandle)
.Start();
// Bind to second security candles
secondSecuritySubscription
.Bind(ProcessSecondSecurityCandle)
.Start();
// Enable position protection with stop-loss
StartProtection(
takeProfit: new Unit(0, UnitTypes.Absolute), // No take-profit
stopLoss: new Unit(StopLossPercent, UnitTypes.Percent) // Stop-loss as percentage
);
// Setup chart if available
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, firstSecuritySubscription);
DrawIndicator(area, _firstMA);
DrawOwnTrades(area);
}
}
private void ProcessFirstSecurityCandle(ICandleMessage candle, decimal firstMAValue)
{
// Skip unfinished candles
if (candle.State != CandleStates.Finished)
return;
// Store current price
_lastFirstPrice = candle.ClosePrice;
// Skip if we don't have both prices or if indicators aren't formed
if (_lastSecondPrice == 0 || !_firstMA.IsFormed || !_secondMA.IsFormed)
return;
// Get last second MA value stored earlier
decimal secondMAValue = _secondMAValue;
// Trading logic
bool isFirstBelowMA = _lastFirstPrice < firstMAValue;
bool isSecondAboveMA = _lastSecondPrice > secondMAValue;
bool isFirstAboveMA = _lastFirstPrice > firstMAValue;
bool isSecondBelowMA = _lastSecondPrice < secondMAValue;
decimal currentSpread = _lastFirstPrice - _lastSecondPrice;
// Long signal: First asset below MA, Second asset above MA
if (isFirstBelowMA && isSecondAboveMA)
{
// If we're not already in a long position
if (Position <= 0)
{
BuyMarket(Volume + Math.Abs(Position));
_entrySpread = currentSpread;
LogInfo($"Long Signal: {Security.Code}({_lastFirstPrice:F4}) < MA({firstMAValue:F4}) && " +
$"{SecondSecurity.Code}({_lastSecondPrice:F4}) > MA({secondMAValue:F4})");
// Note: In a real implementation, you would also place a sell order
// for the second security here, using a different strategy instance or connector
}
}
// Short signal: First asset above MA, Second asset below MA
else if (isFirstAboveMA && isSecondBelowMA)
{
// If we're not already in a short position
if (Position >= 0)
{
SellMarket(Volume + Math.Abs(Position));
_entrySpread = currentSpread;
LogInfo($"Short Signal: {Security.Code}({_lastFirstPrice:F4}) > MA({firstMAValue:F4}) && " +
$"{SecondSecurity.Code}({_lastSecondPrice:F4}) < MA({secondMAValue:F4})");
// Note: In a real implementation, you would also place a buy order
// for the second security here, using a different strategy instance or connector
}
}
// Exit signals
else if ((Position > 0 && isFirstAboveMA) || (Position < 0 && isFirstBelowMA))
{
// Exit position when first asset crosses its moving average
if (Position > 0)
{
SellMarket(Math.Abs(Position));
LogInfo($"Exit Long: {Security.Code}({_lastFirstPrice:F4}) > MA({firstMAValue:F4})");
}
else if (Position < 0)
{
BuyMarket(Math.Abs(Position));
LogInfo($"Exit Short: {Security.Code}({_lastFirstPrice:F4}) < MA({firstMAValue:F4})");
}
}
}
private void ProcessSecondSecurityCandle(ICandleMessage candle)
{
// Skip unfinished candles
if (candle.State != CandleStates.Finished)
return;
// Store current price
_lastSecondPrice = candle.ClosePrice;
// Process through MA indicator and store last value
var maValue = _secondMA.Process(new DecimalIndicatorValue(_secondMA, candle.ClosePrice, candle.ServerTime) { IsFinal = true });
_secondMAValue = maValue.ToDecimal();
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.BusinessEntities")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan, Math
from StockSharp.Messages import DataType, CandleStates, Unit, UnitTypes, ICandleMessage
from StockSharp.Algo.Indicators import SimpleMovingAverage
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Security
from datatype_extensions import *
from indicator_extensions import *
class statistical_arbitrage_strategy(Strategy):
"""
Statistical Arbitrage strategy that trades pairs of securities based on their relative mean reversion.
Enters when one asset is below its mean while the other is above its mean.
"""
def __init__(self):
super(statistical_arbitrage_strategy, self).__init__()
# Initialize strategy parameters
self._lookback_period = self.Param("LookbackPeriod", 20) \
.SetGreaterThanZero() \
.SetDisplay("Lookback Period", "Period for calculating moving averages", "Parameters") \
.SetCanOptimize(True) \
.SetOptimize(10, 30, 5)
self._stop_loss_percent = self.Param("StopLossPercent", 2.0) \
.SetGreaterThanZero() \
.SetDisplay("Stop-loss %", "Stop-loss as percentage of entry price", "Risk Management") \
.SetCanOptimize(True) \
.SetOptimize(1.0, 3.0, 0.5)
self._candle_type = self.Param("CandleType", tf(15)) \
.SetDisplay("Candle Type", "Type of candles to use", "General")
self._second_security = self.Param[Security]("SecondSecurity", None) \
.SetDisplay("Second Security", "Second security in the pair", "General") \
.SetRequired()
# State variables
self._first_ma = None
self._second_ma = None
self._last_first_price = 0
self._last_second_price = 0
self._entry_spread = 0
self._second_ma_value = 0
@property
def LookbackPeriod(self):
return self._lookback_period.Value
@LookbackPeriod.setter
def LookbackPeriod(self, value):
self._lookback_period.Value = value
@property
def StopLossPercent(self):
return self._stop_loss_percent.Value
@StopLossPercent.setter
def StopLossPercent(self, value):
self._stop_loss_percent.Value = value
@property
def CandleType(self):
return self._candle_type.Value
@CandleType.setter
def CandleType(self, value):
self._candle_type.Value = value
@property
def SecondSecurity(self):
return self._second_security.Value
@SecondSecurity.setter
def SecondSecurity(self, value):
self._second_security.Value = value
def GetWorkingSecurities(self):
"""!! REQUIRED !! Returns securities for strategy."""
return [
(self.Security, self.CandleType),
(self.SecondSecurity, self.CandleType)
]
def OnReseted(self):
"""Resets internal state when strategy is reset."""
super(statistical_arbitrage_strategy, self).OnReseted()
self._first_ma = None
self._second_ma = None
self._last_first_price = 0
self._last_second_price = 0
self._entry_spread = 0
self._second_ma_value = 0
def OnStarted2(self, time):
"""
Called when the strategy starts. Sets up indicators, subscriptions, and charting.
:param time: The time when the strategy started.
"""
super(statistical_arbitrage_strategy, self).OnStarted2(time)
if self.SecondSecurity is None:
raise Exception("Second security is not specified.")
# Initialize indicators
self._first_ma = SimpleMovingAverage()
self._first_ma.Length = self.LookbackPeriod
self._second_ma = SimpleMovingAverage()
self._second_ma.Length = self.LookbackPeriod
# Create subscriptions for both securities
first_security_subscription = self.SubscribeCandles(self.CandleType)
second_security_subscription = self.SubscribeCandles(self.CandleType, security=self.SecondSecurity)
# Bind to first security candles
first_security_subscription.Bind(self._first_ma, self.ProcessFirstSecurityCandle).Start()
# Bind to second security candles
second_security_subscription.Bind(self.ProcessSecondSecurityCandle).Start()
# Enable position protection with stop-loss
self.StartProtection(
takeProfit=Unit(0, UnitTypes.Absolute),
stopLoss=Unit(self.StopLossPercent, UnitTypes.Percent)
)
# Setup chart if available
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, first_security_subscription)
self.DrawIndicator(area, self._first_ma)
self.DrawOwnTrades(area)
def ProcessFirstSecurityCandle(self, candle, first_ma_value):
"""
Process candle of the first security.
:param candle: The candle message.
:param first_ma_value: The moving average value for the first security.
"""
# Skip unfinished candles
if candle.State != CandleStates.Finished:
return
# Skip if strategy is not ready to trade
# Store current price
self._last_first_price = float(candle.ClosePrice)
# Skip if we don't have both prices or if indicators aren't formed
if self._last_second_price == 0 or not self._first_ma.IsFormed or not self._second_ma.IsFormed:
return
# Get last second MA value stored earlier
second_ma_value = self._second_ma_value
# Trading logic
is_first_below_ma = self._last_first_price < first_ma_value
is_second_above_ma = self._last_second_price > second_ma_value
is_first_above_ma = self._last_first_price > first_ma_value
is_second_below_ma = self._last_second_price < second_ma_value
current_spread = self._last_first_price - self._last_second_price
# Long signal: First asset below MA, Second asset above MA
if is_first_below_ma and is_second_above_ma:
# If we're not already in a long position
if self.Position <= 0:
self.BuyMarket(self.Volume + Math.Abs(self.Position))
self._entry_spread = current_spread
self.LogInfo(
"Long Signal: {0}({1:F4}) < MA({2:F4}) && {3}({4:F4}) > MA({5:F4})".format(
self.Security.Code, self._last_first_price, first_ma_value,
self.SecondSecurity.Code, self._last_second_price, second_ma_value))
# Note: In a real implementation, you would also place a sell order
# for the second security here, using a different strategy instance or connector
# Short signal: First asset above MA, Second asset below MA
elif is_first_above_ma and is_second_below_ma:
# If we're not already in a short position
if self.Position >= 0:
self.SellMarket(self.Volume + Math.Abs(self.Position))
self._entry_spread = current_spread
self.LogInfo(
"Short Signal: {0}({1:F4}) > MA({2:F4}) && {3}({4:F4}) < MA({5:F4})".format(
self.Security.Code, self._last_first_price, first_ma_value,
self.SecondSecurity.Code, self._last_second_price, second_ma_value))
# Note: In a real implementation, you would also place a buy order
# for the second security here, using a different strategy instance or connector
# Exit signals
elif (self.Position > 0 and is_first_above_ma) or (self.Position < 0 and is_first_below_ma):
# Exit position when first asset crosses its moving average
if self.Position > 0:
self.SellMarket(Math.Abs(self.Position))
self.LogInfo(
"Exit Long: {0}({1:F4}) > MA({2:F4})".format(
self.Security.Code, self._last_first_price, first_ma_value))
elif self.Position < 0:
self.BuyMarket(Math.Abs(self.Position))
self.LogInfo(
"Exit Short: {0}({1:F4}) < MA({2:F4})".format(
self.Security.Code, self._last_first_price, first_ma_value))
def ProcessSecondSecurityCandle(self, candle):
"""
Process candle of the second security.
:param candle: The second security candle message.
"""
# Skip unfinished candles
if candle.State != CandleStates.Finished:
return
# Store current price
self._last_second_price = float(candle.ClosePrice)
# Process through MA indicator and remember the result
self._second_ma_value = float(
process_float(
self._second_ma,
candle.ClosePrice,
candle.ServerTime,
candle.State == CandleStates.Finished,
)
)
def CreateClone(self):
"""
!! REQUIRED!! Creates a new instance of the strategy.
"""
return statistical_arbitrage_strategy()