Арбитраж спот-фьючерс
Стратегия арбитража между спот-активом и его фьючерсным контрактом. Открывает длинный спот/короткий фьючерс при положительном спрэде и наоборот при отрицательном. Порог может быть динамическим на основе среднего и стандартного отклонения спрэда. Позиции закрываются при возврате спрэда или по истечении максимального времени удержания.
Параметры
- Spot — инструмент спот.
- Future — фьючерсный инструмент.
- CandleType — тип используемых свечей.
- MinSpreadPct — минимальный процент спрэда для входа.
- LookbackPeriod — период для расчёта статистики спрэда.
- AdaptiveThreshold — включить динамические пороги.
- MaxHoldHours — максимальное время удержания позиции в часах.
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>
/// Spot-futures arbitrage strategy using spread thresholds.
/// Opens long spot/short futures or short spot/long futures based on spread deviation.
/// </summary>
public class SpotFuturesArbitrageStrategy : Strategy
{
private readonly StrategyParam<Security> _spot;
private readonly StrategyParam<Security> _future;
private readonly StrategyParam<decimal> _minSpreadPct;
private readonly StrategyParam<int> _lookback;
private readonly StrategyParam<bool> _adaptive;
private readonly StrategyParam<int> _maxHoldHours;
private readonly StrategyParam<DataType> _candleType;
private SMA _spreadAverage;
private StandardDeviation _spreadStd;
private decimal _spotPrice;
private decimal _futurePrice;
private bool _isLong;
private DateTimeOffset _entryTime;
/// <summary>
/// Spot security.
/// </summary>
public Security Spot
{
get => _spot.Value;
set => _spot.Value = value;
}
/// <summary>
/// Futures security.
/// </summary>
public Security Future
{
get => _future.Value;
set => _future.Value = value;
}
/// <summary>
/// Minimum spread percentage to enter.
/// </summary>
public decimal MinSpreadPct
{
get => _minSpreadPct.Value;
set => _minSpreadPct.Value = value;
}
/// <summary>
/// Lookback period for spread statistics.
/// </summary>
public int LookbackPeriod
{
get => _lookback.Value;
set => _lookback.Value = value;
}
/// <summary>
/// Enable adaptive thresholds.
/// </summary>
public bool AdaptiveThreshold
{
get => _adaptive.Value;
set => _adaptive.Value = value;
}
/// <summary>
/// Maximum holding time in hours.
/// </summary>
public int MaxHoldHours
{
get => _maxHoldHours.Value;
set => _maxHoldHours.Value = value;
}
/// <summary>
/// Candle type.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Constructor.
/// </summary>
public SpotFuturesArbitrageStrategy()
{
_spot = Param<Security>(nameof(Spot), null)
.SetDisplay("Spot", "Spot security", "General");
_future = Param<Security>(nameof(Future), null)
.SetDisplay("Future", "Futures security", "General");
_minSpreadPct = Param(nameof(MinSpreadPct), 0.05m)
.SetGreaterThanZero()
.SetDisplay("Min Spread %", "Minimum spread percentage to enter", "General");
_lookback = Param(nameof(LookbackPeriod), 5)
.SetGreaterThanZero()
.SetDisplay("Lookback", "Period for spread statistics", "General");
_adaptive = Param(nameof(AdaptiveThreshold), true)
.SetDisplay("Adaptive Threshold", "Use dynamic thresholds", "General");
_maxHoldHours = Param(nameof(MaxHoldHours), 6)
.SetGreaterThanZero()
.SetDisplay("Max Hold Hours", "Maximum holding time in hours", "Risk");
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(1).TimeFrame())
.SetDisplay("Candle Type", "Type of candles to use", "General");
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
if (Spot == null || Future == null)
throw new InvalidOperationException("Both spot and futures securities must be set.");
return [(Spot, CandleType), (Future, CandleType)];
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_spotPrice = 0m;
_futurePrice = 0m;
_isLong = false;
_entryTime = default;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
if (Spot == null || Future == null)
throw new InvalidOperationException("Both spot and futures securities must be set.");
base.OnStarted2(time);
_spreadAverage = new SMA { Length = LookbackPeriod };
_spreadStd = new StandardDeviation { Length = LookbackPeriod };
var spotSub = SubscribeCandles(CandleType, true, Spot)
.Bind(c => ProcessCandle(c, true))
.Start();
SubscribeCandles(CandleType, true, Future)
.Bind(c => ProcessCandle(c, false))
.Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, spotSub);
DrawOwnTrades(area);
}
}
private void ProcessCandle(ICandleMessage candle, bool isSpot)
{
if (candle.State != CandleStates.Finished)
return;
if (isSpot)
_spotPrice = candle.ClosePrice;
else
_futurePrice = candle.ClosePrice;
if (_spotPrice <= 0m || _futurePrice <= 0m)
return;
var spread = (_futurePrice - _spotPrice) / _spotPrice;
var avg = _spreadAverage.Process(new DecimalIndicatorValue(_spreadAverage, spread, candle.ServerTime)).ToDecimal();
var std = _spreadStd.Process(new DecimalIndicatorValue(_spreadStd, spread, candle.ServerTime)).ToDecimal();
var minSpread = MinSpreadPct / 100m;
var entryLong = minSpread;
var entryShort = -minSpread;
if (AdaptiveThreshold && _spreadAverage.IsFormed && _spreadStd.IsFormed)
{
entryLong = Math.Max(minSpread, avg + std * 1.5m);
entryShort = Math.Min(-minSpread, avg - std * 1.5m);
}
var exitThreshold = 0.6m;
var now = candle.CloseTime;
var spotPos = PositionBy(Spot);
var futPos = PositionBy(Future);
var hasPosition = spotPos != 0m || futPos != 0m;
if (!hasPosition)
{
var volume = ComputeVolume();
if (volume <= 0m)
return;
if (spread >= entryLong)
{
Buy(Spot, volume);
Sell(Future, volume);
_isLong = true;
_entryTime = now;
}
else if (spread <= entryShort)
{
Sell(Spot, volume);
Buy(Future, volume);
_isLong = false;
_entryTime = now;
}
}
else
{
var timeExpired = (now - _entryTime) >= TimeSpan.FromHours(MaxHoldHours);
var shouldExit = _isLong ? spread < entryLong * exitThreshold : spread > entryShort * exitThreshold;
if (shouldExit || timeExpired)
{
if (spotPos != 0m)
{
RegisterOrder(new Order
{
Security = Spot,
Portfolio = Portfolio,
Side = spotPos > 0m ? Sides.Sell : Sides.Buy,
Volume = Math.Abs(spotPos),
Type = OrderTypes.Market,
});
}
if (futPos != 0m)
{
RegisterOrder(new Order
{
Security = Future,
Portfolio = Portfolio,
Side = futPos > 0m ? Sides.Sell : Sides.Buy,
Volume = Math.Abs(futPos),
Type = OrderTypes.Market,
});
}
_isLong = false;
_entryTime = default;
}
}
}
private decimal ComputeVolume()
{
var equity = Portfolio.CurrentValue ?? 0m;
if (_spotPrice <= 0m || equity <= 0m)
return 0m;
return equity * 0.3m / _spotPrice;
}
private void Buy(Security security, decimal volume)
{
RegisterOrder(new Order
{
Security = security,
Portfolio = Portfolio,
Side = Sides.Buy,
Volume = volume,
Type = OrderTypes.Market,
});
}
private void Sell(Security security, decimal volume)
{
RegisterOrder(new Order
{
Security = security,
Portfolio = Portfolio,
Side = Sides.Sell,
Volume = volume,
Type = OrderTypes.Market,
});
}
private decimal PositionBy(Security security) => GetPositionValue(security, Portfolio) ?? 0m;
}
import clr
clr.AddReference("StockSharp.Messages")
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, Sides, OrderTypes
from StockSharp.Algo.Indicators import SimpleMovingAverage as SMA, StandardDeviation
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Order
from indicator_extensions import *
class spot_futures_arbitrage_strategy(Strategy):
def __init__(self):
super(spot_futures_arbitrage_strategy, self).__init__()
self._spot = self.Param("Spot", None) \
.SetDisplay("Spot", "Spot security", "General")
self._future = self.Param("Future", None) \
.SetDisplay("Future", "Futures security", "General")
self._min_spread_pct = self.Param("MinSpreadPct", 0.05) \
.SetDisplay("Min Spread %", "Minimum spread percentage to enter", "General")
self._lookback = self.Param("LookbackPeriod", 5) \
.SetDisplay("Lookback", "Period for spread statistics", "General")
self._adaptive = self.Param("AdaptiveThreshold", True) \
.SetDisplay("Adaptive Threshold", "Use dynamic thresholds", "General")
self._max_hold_hours = self.Param("MaxHoldHours", 6) \
.SetDisplay("Max Hold Hours", "Maximum holding time in hours", "Risk")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(1))) \
.SetDisplay("Candle Type", "Type of candles to use", "General")
self._spot_price = 0.0
self._future_price = 0.0
self._is_long = False
self._entry_time = None
@property
def spot(self):
return self._spot.Value
@property
def future(self):
return self._future.Value
@property
def min_spread_pct(self):
return self._min_spread_pct.Value
@property
def lookback_period(self):
return self._lookback.Value
@property
def adaptive_threshold(self):
return self._adaptive.Value
@property
def max_hold_hours(self):
return self._max_hold_hours.Value
@property
def candle_type(self):
return self._candle_type.Value
def OnReseted(self):
super(spot_futures_arbitrage_strategy, self).OnReseted()
self._spot_price = 0.0
self._future_price = 0.0
self._is_long = False
self._entry_time = None
def OnStarted2(self, time):
super(spot_futures_arbitrage_strategy, self).OnStarted2(time)
self._spread_average = SMA()
self._spread_average.Length = self.lookback_period
self._spread_std = StandardDeviation()
self._spread_std.Length = self.lookback_period
spot_sub = self.SubscribeCandles(self.candle_type, True, self.spot)
spot_sub.Bind(lambda c: self._process_candle(c, True)).Start()
self.SubscribeCandles(self.candle_type, True, self.future) \
.Bind(lambda c: self._process_candle(c, False)).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, spot_sub)
self.DrawOwnTrades(area)
def _process_candle(self, candle, is_spot):
if candle.State != CandleStates.Finished:
return
if is_spot:
self._spot_price = float(candle.ClosePrice)
else:
self._future_price = float(candle.ClosePrice)
if self._spot_price <= 0 or self._future_price <= 0:
return
spread = (self._future_price - self._spot_price) / self._spot_price
avg_val = process_float(self._spread_average, spread, candle.ServerTime, True)
avg = float(avg_val)
std_val = process_float(self._spread_std, spread, candle.ServerTime, True)
std = float(std_val)
min_spread = float(self.min_spread_pct) / 100.0
entry_long = min_spread
entry_short = -min_spread
if self.adaptive_threshold and self._spread_average.IsFormed and self._spread_std.IsFormed:
entry_long = max(min_spread, avg + std * 1.5)
entry_short = min(-min_spread, avg - std * 1.5)
exit_threshold = 0.6
now = candle.CloseTime
spot_pos = float(self.GetPositionValue(self.spot, self.Portfolio) or 0)
fut_pos = float(self.GetPositionValue(self.future, self.Portfolio) or 0)
has_position = spot_pos != 0 or fut_pos != 0
if not has_position:
if spread >= entry_long:
self._buy_security(self.spot)
self._sell_security(self.future)
self._is_long = True
self._entry_time = now
elif spread <= entry_short:
self._sell_security(self.spot)
self._buy_security(self.future)
self._is_long = False
self._entry_time = now
else:
time_expired = self._entry_time is not None and (now - self._entry_time) >= TimeSpan.FromHours(int(self.max_hold_hours))
if self._is_long:
should_exit = spread < entry_long * exit_threshold
else:
should_exit = spread > entry_short * exit_threshold
if should_exit or time_expired:
if spot_pos != 0:
order = Order()
order.Security = self.spot
order.Portfolio = self.Portfolio
order.Side = Sides.Sell if spot_pos > 0 else Sides.Buy
order.Volume = abs(spot_pos)
order.Type = OrderTypes.Market
self.RegisterOrder(order)
if fut_pos != 0:
order = Order()
order.Security = self.future
order.Portfolio = self.Portfolio
order.Side = Sides.Sell if fut_pos > 0 else Sides.Buy
order.Volume = abs(fut_pos)
order.Type = OrderTypes.Market
self.RegisterOrder(order)
self._is_long = False
self._entry_time = None
def _buy_security(self, security):
order = Order()
order.Security = security
order.Portfolio = self.Portfolio
order.Side = Sides.Buy
order.Volume = self.Volume
order.Type = OrderTypes.Market
self.RegisterOrder(order)
def _sell_security(self, security):
order = Order()
order.Security = security
order.Portfolio = self.Portfolio
order.Side = Sides.Sell
order.Volume = self.Volume
order.Type = OrderTypes.Market
self.RegisterOrder(order)
def CreateClone(self):
return spot_futures_arbitrage_strategy()