Soccer Clubs Arbitrage
This strategy seeks arbitrage opportunities between soccer club fan tokens traded on multiple venues. By watching price spreads and funding rate imbalances, it opens offsetting long and short positions to lock in mispricings.
A trade triggers when the spread between exchanges exceeds a threshold. Positions are hedged and exited when prices converge or a protective stop is reached.
Details
- Data: Fan token prices and funding rates.
- Entry: Open opposite positions when spread > X%.
- Exit: Close when spread < Y% or at time stop.
- Instruments: Exchange‑listed fan tokens.
- Risk: Fixed‑percent stop to guard against slippage.
// SoccerClubsArbitrageStrategy.cs
// -----------------------------------------------------------------------------
// Two share classes of the same soccer club (pair length = 2).
// Long cheaper share, short expensive when relative premium > EntryThresh;
// exit when premium shrinks below ExitThresh.
// Uses candle-based price comparison between two securities.
// -----------------------------------------------------------------------------
// Date: 2 Aug 2025
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using Ecng.Common;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Configuration;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Arbitrage strategy for two share classes of the same soccer club.
/// </summary>
public class SoccerClubsArbitrageStrategy : Strategy
{
private readonly StrategyParam<string> _security2Id;
private readonly StrategyParam<decimal> _entry;
private readonly StrategyParam<decimal> _exit;
private readonly StrategyParam<int> _cooldownBars;
private readonly StrategyParam<DataType> _candleType;
/// <summary>
/// Second security identifier.
/// </summary>
public string Security2Id
{
get => _security2Id.Value;
set => _security2Id.Value = value;
}
/// <summary>
/// Premium threshold to enter a position.
/// </summary>
public decimal EntryThreshold
{
get => _entry.Value;
set => _entry.Value = value;
}
/// <summary>
/// Premium threshold to exit a position.
/// </summary>
public decimal ExitThreshold
{
get => _exit.Value;
set => _exit.Value = value;
}
/// <summary>
/// Cooldown bars between trades.
/// </summary>
public int CooldownBars
{
get => _cooldownBars.Value;
set => _cooldownBars.Value = value;
}
/// <summary>
/// The type of candles to use for strategy calculation.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
private Security _secondSecurity;
private decimal _priceA;
private decimal _priceB;
private bool _primaryUpdated;
private bool _secondUpdated;
private int _cooldownRemaining;
public SoccerClubsArbitrageStrategy()
{
_security2Id = Param(nameof(Security2Id), Paths.HistoryDefaultSecurity2)
.SetDisplay("Second Security Id", "Identifier of the second security", "General");
_entry = Param(nameof(EntryThreshold), 0.005m)
.SetDisplay("Entry Threshold", "Premium difference to open position", "Parameters");
_exit = Param(nameof(ExitThreshold), 0.001m)
.SetDisplay("Exit Threshold", "Premium difference to close position", "Parameters");
_cooldownBars = Param(nameof(CooldownBars), 5)
.SetDisplay("Cooldown Bars", "Bars to wait between trades", "Risk");
_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()
{
if (Security != null)
yield return (Security, CandleType);
if (!Security2Id.IsEmpty())
yield return (new Security { Id = Security2Id }, CandleType);
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_secondSecurity = null;
_priceA = 0;
_priceB = 0;
_primaryUpdated = false;
_secondUpdated = false;
_cooldownRemaining = 0;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
if (Security2Id.IsEmpty())
throw new InvalidOperationException("Second security identifier is not specified.");
_secondSecurity = this.LookupById(Security2Id) ?? new Security { Id = Security2Id };
// Subscribe to primary security candles
var primarySub = SubscribeCandles(CandleType, security: Security);
primarySub
.Bind(ProcessPrimaryCandle)
.Start();
// Subscribe to second security candles
var secondSub = SubscribeCandles(CandleType, security: _secondSecurity);
secondSub
.Bind(ProcessSecondCandle)
.Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, primarySub);
DrawOwnTrades(area);
}
}
private void ProcessPrimaryCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
_priceA = candle.ClosePrice;
_primaryUpdated = true;
TryEvaluate();
}
private void ProcessSecondCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
_priceB = candle.ClosePrice;
_secondUpdated = true;
TryEvaluate();
}
private void TryEvaluate()
{
if (!_primaryUpdated || !_secondUpdated)
return;
if (_priceA <= 0 || _priceB <= 0)
return;
_primaryUpdated = false;
_secondUpdated = false;
if (!IsFormedAndOnlineAndAllowTrading())
return;
if (_cooldownRemaining > 0)
{
_cooldownRemaining--;
return;
}
var premium = _priceA / _priceB - 1m;
var primaryPos = GetPositionValue(Security, Portfolio) ?? 0m;
// Exit when premium shrinks below exit threshold
if (Math.Abs(premium) < ExitThreshold && primaryPos != 0)
{
Flatten(primaryPos);
_cooldownRemaining = CooldownBars;
return;
}
// A is overpriced relative to B -> short A, long B
if (premium > EntryThreshold && primaryPos >= 0)
{
if (primaryPos > 0)
Flatten(primaryPos);
SellMarket(Volume);
_cooldownRemaining = CooldownBars;
}
// B is overpriced relative to A -> long A, short B
else if (premium < -EntryThreshold && primaryPos <= 0)
{
if (primaryPos < 0)
Flatten(primaryPos);
BuyMarket(Volume);
_cooldownRemaining = CooldownBars;
}
}
private void Flatten(decimal primaryPos)
{
if (primaryPos > 0)
SellMarket(primaryPos);
else if (primaryPos < 0)
BuyMarket(Math.Abs(primaryPos));
}
}
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
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Security
class soccer_clubs_arbitrage_strategy(Strategy):
"""Arbitrage strategy for two share classes of the same soccer club."""
def __init__(self):
super(soccer_clubs_arbitrage_strategy, self).__init__()
self._security2_id = self.Param("Security2Id", "TONUSDT@BNBFT") \
.SetDisplay("Second Security Id", "Identifier of the second security", "General")
self._entry = self.Param("EntryThreshold", 0.005) \
.SetDisplay("Entry Threshold", "Premium difference to open position", "Parameters")
self._exit = self.Param("ExitThreshold", 0.001) \
.SetDisplay("Exit Threshold", "Premium difference to close position", "Parameters")
self._cooldown_bars = self.Param("CooldownBars", 5) \
.SetDisplay("Cooldown Bars", "Bars to wait between trades", "Risk")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(5))) \
.SetDisplay("Candle Type", "Type of candles to use", "General")
self._second_security = None
self._price_a = 0.0
self._price_b = 0.0
self._primary_updated = False
self._second_updated = False
self._cooldown_remaining = 0
@property
def candle_type(self):
return self._candle_type.Value
def OnReseted(self):
super(soccer_clubs_arbitrage_strategy, self).OnReseted()
self._second_security = None
self._price_a = 0.0
self._price_b = 0.0
self._primary_updated = False
self._second_updated = False
self._cooldown_remaining = 0
def OnStarted2(self, time):
super(soccer_clubs_arbitrage_strategy, self).OnStarted2(time)
sec2_id = str(self._security2_id.Value)
if not sec2_id:
raise Exception("Second security identifier is not specified.")
s = Security()
s.Id = sec2_id
self._second_security = s
primary_sub = self.SubscribeCandles(self.candle_type, True, self.Security)
second_sub = self.SubscribeCandles(self.candle_type, True, self._second_security)
primary_sub.Bind(self._process_primary_candle).Start()
second_sub.Bind(self._process_second_candle).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, primary_sub)
self.DrawOwnTrades(area)
def _process_primary_candle(self, candle):
if candle.State != CandleStates.Finished:
return
self._price_a = float(candle.ClosePrice)
self._primary_updated = True
self._try_evaluate()
def _process_second_candle(self, candle):
if candle.State != CandleStates.Finished:
return
self._price_b = float(candle.ClosePrice)
self._second_updated = True
self._try_evaluate()
def _try_evaluate(self):
if not self._primary_updated or not self._second_updated:
return
if self._price_a <= 0 or self._price_b <= 0:
return
self._primary_updated = False
self._second_updated = False
if not self.IsFormedAndOnlineAndAllowTrading():
return
if self._cooldown_remaining > 0:
self._cooldown_remaining -= 1
return
premium = self._price_a / self._price_b - 1.0
entry_thresh = float(self._entry.Value)
exit_thresh = float(self._exit.Value)
cooldown = int(self._cooldown_bars.Value)
primary_pos = self.GetPositionValue(self.Security, self.Portfolio)
if primary_pos is None:
primary_pos = 0
primary_pos = float(primary_pos)
if abs(premium) < exit_thresh and primary_pos != 0:
self._flatten(primary_pos)
self._cooldown_remaining = cooldown
return
if premium > entry_thresh and primary_pos >= 0:
if primary_pos > 0:
self._flatten(primary_pos)
self.SellMarket(self.Volume)
self._cooldown_remaining = cooldown
elif premium < -entry_thresh and primary_pos <= 0:
if primary_pos < 0:
self._flatten(primary_pos)
self.BuyMarket(self.Volume)
self._cooldown_remaining = cooldown
def _flatten(self, primary_pos):
if primary_pos > 0:
self.SellMarket(primary_pos)
elif primary_pos < 0:
self.BuyMarket(abs(primary_pos))
def CreateClone(self):
return soccer_clubs_arbitrage_strategy()