Arbitraje Spot-Futuros
Arbitra la diferencia de precio entre un activo spot y su contrato de futuros. Entra largo en spot/corto en futuros cuando el futuro cotiza por encima del spot en un umbral, y lo opuesto cuando está por debajo. Los umbrales pueden ser dinámicos basados en la media y desviación estándar del spread, y las operaciones se cierran cuando el spread revierte o tras un tiempo máximo de mantenimiento.
Parámetros
- Spot — activo spot.
- Future — activo de futuros.
- CandleType — marco temporal de la vela.
- MinSpreadPct — porcentaje mínimo de spread para entrar.
- LookbackPeriod — período para estadísticas del spread.
- AdaptiveThreshold — activar umbrales dinámicos.
- MaxHoldHours — tiempo máximo de mantenimiento de la posición en horas.
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 Order _spotOrder;
private Order _futureOrder;
private decimal _spotPrice;
private decimal _futurePrice;
private decimal _entryVolume;
private bool _isLong;
private bool _inPosition;
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();
_spotOrder = null;
_futureOrder = null;
_spotPrice = 0m;
_futurePrice = 0m;
_entryVolume = 0m;
_isLong = false;
_inPosition = 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;
// The pair is opened and closed as a whole, so a new decision may not be taken while
// an earlier market order of either leg is still working.
if (IsWorking(_spotOrder) || IsWorking(_futureOrder))
return;
if (!_inPosition)
{
var volume = Volume;
if (spread >= entryLong)
{
_spotOrder = Register(Spot, Sides.Buy, volume);
_futureOrder = Register(Future, Sides.Sell, volume);
_isLong = true;
_inPosition = true;
_entryVolume = volume;
_entryTime = now;
}
else if (spread <= entryShort)
{
_spotOrder = Register(Spot, Sides.Sell, volume);
_futureOrder = Register(Future, Sides.Buy, volume);
_isLong = false;
_inPosition = true;
_entryVolume = volume;
_entryTime = now;
}
}
else
{
var timeExpired = (now - _entryTime) >= TimeSpan.FromHours(MaxHoldHours);
var shouldExit = _isLong ? spread < entryLong * exitThreshold : spread > entryShort * exitThreshold;
if (shouldExit || timeExpired)
{
// Each leg is closed with exactly the volume it was opened with, so the
// order size never depends on the results of the previous cycle.
_spotOrder = Register(Spot, _isLong ? Sides.Sell : Sides.Buy, _entryVolume);
_futureOrder = Register(Future, _isLong ? Sides.Buy : Sides.Sell, _entryVolume);
_isLong = false;
_inPosition = false;
_entryVolume = 0m;
_entryTime = default;
}
}
}
private static bool IsWorking(Order order)
=> order != null && order.State is not (OrderStates.Done or OrderStates.Failed);
private Order Register(Security security, Sides side, decimal volume)
{
var order = new Order
{
Security = security,
Portfolio = Portfolio,
Side = side,
Volume = volume,
Type = OrderTypes.Market,
};
RegisterOrder(order);
return order;
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
clr.AddReference("StockSharp.BusinessEntities")
from System import TimeSpan, Math, InvalidOperationException
from StockSharp.Messages import DataType, CandleStates, Sides, OrderTypes, OrderStates
from StockSharp.Algo.Indicators import SimpleMovingAverage as SMA, StandardDeviation
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Order, Security
from indicator_extensions import *
class spot_futures_arbitrage_strategy(Strategy):
def __init__(self):
super(spot_futures_arbitrage_strategy, self).__init__()
self._spot = self.Param[Security]("Spot", None) \
.SetDisplay("Spot", "Spot security", "General")
self._future = self.Param[Security]("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_order = None
self._future_order = None
self._spot_price = 0.0
self._future_price = 0.0
self._entry_volume = 0.0
self._is_long = False
self._in_position = 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 GetWorkingSecurities(self):
if self.spot is None or self.future is None:
raise InvalidOperationException("Both spot and futures securities must be set.")
return [(self.spot, self.candle_type), (self.future, self.candle_type)]
def OnReseted(self):
super(spot_futures_arbitrage_strategy, self).OnReseted()
self._spot_order = None
self._future_order = None
self._spot_price = 0.0
self._future_price = 0.0
self._entry_volume = 0.0
self._is_long = False
self._in_position = False
self._entry_time = None
def OnStarted2(self, time):
if self.spot is None or self.future is None:
raise InvalidOperationException("Both spot and futures securities must be set.")
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
# The pair is opened and closed as a whole, so a new decision may not be taken while
# an earlier market order of either leg is still working.
if self._is_working(self._spot_order) or self._is_working(self._future_order):
return
if not self._in_position:
volume = self.Volume
if spread >= entry_long:
self._spot_order = self._register(self.spot, Sides.Buy, volume)
self._future_order = self._register(self.future, Sides.Sell, volume)
self._is_long = True
self._in_position = True
self._entry_volume = volume
self._entry_time = now
elif spread <= entry_short:
self._spot_order = self._register(self.spot, Sides.Sell, volume)
self._future_order = self._register(self.future, Sides.Buy, volume)
self._is_long = False
self._in_position = True
self._entry_volume = volume
self._entry_time = now
else:
time_expired = (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:
# Each leg is closed with exactly the volume it was opened with, so the
# order size never depends on the results of the previous cycle.
spot_side = Sides.Sell if self._is_long else Sides.Buy
future_side = Sides.Buy if self._is_long else Sides.Sell
self._spot_order = self._register(self.spot, spot_side, self._entry_volume)
self._future_order = self._register(self.future, future_side, self._entry_volume)
self._is_long = False
self._in_position = False
self._entry_volume = 0.0
self._entry_time = None
def _is_working(self, order):
return order is not None and order.State != OrderStates.Done and order.State != OrderStates.Failed
def _register(self, security, side, volume):
order = Order()
order.Security = security
order.Portfolio = self.Portfolio
order.Side = side
order.Volume = volume
order.Type = OrderTypes.Market
self.RegisterOrder(order)
return order
def CreateClone(self):
return spot_futures_arbitrage_strategy()