| Overall Statistics |
|
Total Orders 812 Average Win 1.13% Average Loss -0.65% Compounding Annual Return 5719.251% Drawdown 31.400% Expectancy 0.374 Start Equity 5000.0 End Equity 13893.31 Net Profit 177.866% Sharpe Ratio 92.82 Sortino Ratio 136.784 Probabilistic Sharpe Ratio 95.561% Loss Rate 50% Win Rate 50% Profit-Loss Ratio 1.72 Alpha 122.869 Beta 2.16 Annual Standard Deviation 1.325 Annual Variance 1.755 Information Ratio 103.989 Tracking Error 1.182 Treynor Ratio 56.937 Total Fees â‚®2470.54 Estimated Strategy Capacity â‚®530000.00 Lowest Capacity Asset IOTXUSDT 18N Portfolio Turnover 412.94% Drawdown Recovery 19 |
#region imports
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using QuantConnect;
using QuantConnect.Algorithm;
using QuantConnect.Brokerages;
using QuantConnect.Data;
using QuantConnect.Data.Market;
using QuantConnect.Data.UniverseSelection;
using QuantConnect.DataSource;
using QuantConnect.Orders;
using QuantConnect.Orders.Fees;
using QuantConnect.Securities;
using QuantConnect.Securities.Crypto;
using QuantConnect.Securities.CryptoFuture;
#endregion
namespace QuantConnect.Algorithm.CSharp
{
public class MultiPairCryptoFutureArbitrage : QCAlgorithm
{
// =====================================================================================
// CURRENCY CONFIGS
// =====================================================================================
//
// EU-User: "USDC"
// Non-EU User: "USDT"
private string _spotQuoteCurrency = "USDT";
private string _futureQuoteCurrency = "USDT";
// =====================================================================================
// DATA STRUCTURE
// =====================================================================================
private readonly HashSet<string> _knownDustPairs = new HashSet<string>();
private readonly Dictionary<string, DateTime> _quarantinedPairs = new Dictionary<string, DateTime>();
private readonly List<string> _targetPairs = new List<string>();
private readonly Dictionary<string, Symbol> _spotSymbols = new Dictionary<string, Symbol>();
private readonly Dictionary<string, Symbol> _futureSymbols = new Dictionary<string, Symbol>();
private readonly Dictionary<Symbol, double> _multipliers = new Dictionary<Symbol, double>();
private readonly HashSet<string> _activeTradePairs = new HashSet<string>();
private readonly Dictionary<Symbol, int> _priceDecimals = new Dictionary<Symbol, int>();
private readonly Dictionary<string, DateTime> _pairEntryTimes = new Dictionary<string, DateTime>();
private readonly Dictionary<string, decimal> _pairInitialNotional = new Dictionary<string, decimal>();
// =====================================================================================
// ALGORITHM PARAMETER
// =====================================================================================
private int _maxConcurrentPairs = 2;
private double _totalStrategyAllocation = 1.9;
private double _minLiquidationQuoteValue = 11;
private double _minEntryOrderValue = 11;
private double _baseEntryThresholdFactor = 1.014;
private double _baseEntryThresholdFactorInv = 1.014;
private double _exitThresholdFactor = 1.005;
private int _maxHoldingHours = 96;
private decimal _stopLossPct = 1m; // 100% - turned off effectively
private TimeSpan _maxHoldingDuration;
private double _maxPlausibleSpread = 0.25;
private TimeSpan _quarantineDuration = TimeSpan.FromHours(96);
private decimal _spotleverage = 3m;
private decimal _futureleverage = 25m;
private Universe _universe;
public override void Initialize()
{
SetStartDate(2025, 1, 1);
SetEndDate(2025, 4, 1);
SetAccountCurrency(_spotQuoteCurrency);
SetCash(5000);
SetBrokerageModel(BrokerageName.BinanceFutures, AccountType.Margin);
Settings.FreePortfolioValuePercentage = 0.2m;
_maxHoldingDuration = TimeSpan.FromHours(_maxHoldingHours);
int estimatedHoldingHours = 96;
SetWarmUp(TimeSpan.FromHours(estimatedHoldingHours * 2));
_universe = AddUniverse(CryptoUniverse.Binance(UniverseFilter));
}
private IEnumerable<Symbol> UniverseFilter(IEnumerable<CryptoUniverse> universeData)
{
return universeData
.Where(u => u.VolumeInUsd > 1000m
&& u.Price > 0.0000000001m
&& u.Price < 100000000000m
&& u.Symbol.Value.EndsWith(_futureQuoteCurrency, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(u => u.VolumeInUsd)
.Select(u => u.Symbol)
.Take(2000);
}
public override void OnSecuritiesChanged(SecurityChanges changes)
{
if (_spotQuoteCurrency == _futureQuoteCurrency)
{
HandleSameCurrencySecurities(changes);
}
else
{
HandleDifferentCurrencySecurities(changes);
}
}
private void HandleSameCurrencySecurities(SecurityChanges changes)
{
foreach (var security in changes.RemovedSecurities)
{
var spotSymbol = security.Symbol;
var pairKey = spotSymbol.Value;
if (_targetPairs.Contains(pairKey))
{
_futureSymbols.TryGetValue(pairKey, out var futureSymbol);
LiquidatePair(pairKey, spotSymbol, futureSymbol, "UniverseDelisting");
if (futureSymbol != null) RemoveSecurity(futureSymbol);
_targetPairs.Remove(pairKey);
_spotSymbols.Remove(pairKey);
_futureSymbols.Remove(pairKey);
if (futureSymbol != null) _multipliers.Remove(futureSymbol);
_priceDecimals.Remove(spotSymbol);
if (futureSymbol != null) _priceDecimals.Remove(futureSymbol);
}
}
foreach (var spotSecurity in changes.AddedSecurities)
{
var pairKey = spotSecurity.Symbol.Value;
if (_targetPairs.Contains(pairKey)) continue;
try
{
var futureSymbolObj = AddCryptoFuture(pairKey, Resolution.Minute, Market.Binance).Symbol;
var futureSecurity = Securities[futureSymbolObj];
if (spotSecurity != null && futureSecurity != null)
{
spotSecurity.SetFeeModel(new BinanceFeeModel());
futureSecurity.SetFeeModel(new BinanceFuturesFeeModel());
spotSecurity.SetBuyingPowerModel(new SecurityMarginModel(_spotleverage));
futureSecurity.SetLeverage(_futureleverage);
_spotSymbols[pairKey] = spotSecurity.Symbol;
_futureSymbols[pairKey] = futureSymbolObj;
_multipliers[futureSymbolObj] = (double)(futureSecurity.SymbolProperties?.ContractMultiplier ?? 1.0m);
_priceDecimals[spotSecurity.Symbol] = GetDecimalPlaces(spotSecurity);
_priceDecimals[futureSymbolObj] = GetDecimalPlaces(futureSecurity);
_targetPairs.Add(pairKey);
}
}
catch (Exception) { }
}
}
private void HandleDifferentCurrencySecurities(SecurityChanges changes)
{
foreach (var security in changes.RemovedSecurities)
{
var futureSymbol = security.Symbol;
if (!futureSymbol.Value.EndsWith(_futureQuoteCurrency)) continue;
var pairKey = GetBaseCurrency(futureSymbol);
if (!_targetPairs.Contains(pairKey)) continue;
_spotSymbols.TryGetValue(pairKey, out var spotSymbol);
var spotHolding = spotSymbol != null ? Portfolio[spotSymbol] : null;
var futureHolding = Portfolio[futureSymbol];
bool hasSpotDust = spotHolding != null && spotHolding.Invested && Math.Abs(spotHolding.HoldingsValue) > 0 && Math.Abs(spotHolding.HoldingsValue) < (decimal)_minLiquidationQuoteValue;
decimal futureValue = 0m;
if (futureHolding.Invested && _multipliers.ContainsKey(futureSymbol))
{
futureValue = Math.Abs(futureHolding.Quantity * Securities[futureSymbol].Price * (decimal)_multipliers[futureSymbol]);
}
bool hasFutureDust = futureHolding.Invested && futureValue > 0 && futureValue < (decimal)_minLiquidationQuoteValue;
if (hasSpotDust || hasFutureDust)
{
_knownDustPairs.Add(pairKey);
continue;
}
LiquidatePair(pairKey, spotSymbol, futureSymbol, "UniverseDelisting");
if (spotSymbol != null)
{
RemoveSecurity(spotSymbol);
_priceDecimals.Remove(spotSymbol);
}
_multipliers.Remove(futureSymbol);
_priceDecimals.Remove(futureSymbol);
_targetPairs.Remove(pairKey);
_spotSymbols.Remove(pairKey);
_futureSymbols.Remove(pairKey);
}
foreach (var futureSecurity in changes.AddedSecurities)
{
var futureSymbol = futureSecurity.Symbol;
if (!futureSymbol.Value.EndsWith(_futureQuoteCurrency)) continue;
var pairKey = GetBaseCurrency(futureSymbol);
if (_targetPairs.Contains(pairKey)) continue;
try
{
var spotSymbolString = $"{pairKey}{_spotQuoteCurrency}";
var spotSecurity = AddCrypto(spotSymbolString, Resolution.Minute, Market.Binance);
if (spotSecurity != null && futureSecurity != null)
{
spotSecurity.SetFeeModel(new BinanceFeeModel());
futureSecurity.SetFeeModel(new BinanceFuturesFeeModel());
spotSecurity.SetBuyingPowerModel(new SecurityMarginModel(_spotleverage));
futureSecurity.SetLeverage(_futureleverage);
_spotSymbols[pairKey] = spotSecurity.Symbol;
_futureSymbols[pairKey] = futureSymbol;
_multipliers[futureSymbol] = (double)(futureSecurity.SymbolProperties?.ContractMultiplier ?? 1.0m);
_priceDecimals[spotSecurity.Symbol] = GetDecimalPlaces(spotSecurity);
_priceDecimals[futureSymbol] = GetDecimalPlaces(futureSecurity);
_targetPairs.Add(pairKey);
}
}
catch (Exception) { }
}
}
public override void OnData(Slice slice)
{
if (IsWarmingUp) return;
foreach (var pairKey in _targetPairs.ToList())
{
if (!_spotSymbols.TryGetValue(pairKey, out var spotSymbol) ||
!_futureSymbols.TryGetValue(pairKey, out var futureSymbol) ||
!_multipliers.TryGetValue(futureSymbol, out var multiplier))
{
continue;
}
if (!slice.QuoteBars.ContainsKey(spotSymbol) || !slice.QuoteBars.ContainsKey(futureSymbol)) continue;
var spotQuote = slice.QuoteBars[spotSymbol];
var futureQuote = slice.QuoteBars[futureSymbol];
var spotAskPrice = (double)spotQuote.Ask.Close;
var spotBidPrice = (double)spotQuote.Bid.Close;
var futureAskPrice = (double)futureQuote.Ask.Close;
var futureBidPrice = (double)futureQuote.Bid.Close;
if (spotBidPrice <= 0 || futureBidPrice <= 0 || futureAskPrice <= 0) continue;
if (_quarantinedPairs.TryGetValue(pairKey, out var quarantineEndTime))
{
if (Time >= quarantineEndTime)
{
_quarantinedPairs.Remove(pairKey);
}
else
{
continue;
}
}
var isMarketBroken = Math.Abs(spotBidPrice / futureAskPrice - 1) > _maxPlausibleSpread;
var spotHolding = Portfolio[spotSymbol];
var futureHolding = Portfolio[futureSymbol];
if (_activeTradePairs.Contains(pairKey))
{
HandleActiveTradeExit(pairKey, spotSymbol, futureSymbol, spotHolding, futureHolding, spotBidPrice, spotAskPrice, futureBidPrice, futureAskPrice);
}
else if (_activeTradePairs.Count < _maxConcurrentPairs)
{
var spotMidPrice = (spotAskPrice + spotBidPrice) / 2.0;
var futureMidPrice = (futureAskPrice + futureBidPrice) / 2.0;
HandlePotentialTradeEntry(pairKey, spotSymbol, futureSymbol, spotHolding, futureHolding, isMarketBroken, spotBidPrice, spotAskPrice, futureBidPrice, futureAskPrice, spotMidPrice, futureMidPrice, multiplier);
}
}
}
private void HandleActiveTradeExit(string pairKey, Symbol spotSymbol, Symbol futureSymbol, SecurityHolding spotHolding, SecurityHolding futureHolding, double spotBidPrice, double spotAskPrice, double futureBidPrice, double futureAskPrice)
{
bool shouldExit = false;
string exitReason = "";
if (_pairEntryTimes.TryGetValue(pairKey, out var entryTime) && (Time - entryTime) >= _maxHoldingDuration)
{
shouldExit = true;
exitReason = "Max Duration";
if ((spotHolding.UnrealizedProfit + futureHolding.UnrealizedProfit) <= 0 )
{
_quarantinedPairs[pairKey] = Time.Add(_quarantineDuration);
}
}
if (!shouldExit && _pairInitialNotional.TryGetValue(pairKey, out var initialNotional) && initialNotional > 0)
{
decimal currentPnL = spotHolding.UnrealizedProfit + futureHolding.UnrealizedProfit;
decimal stopLossThreshold = -initialNotional * _stopLossPct;
if (currentPnL <= stopLossThreshold)
{
shouldExit = true;
exitReason = "Stop Loss";
_quarantinedPairs[pairKey] = Time.Add(_quarantineDuration);
}
}
if (!shouldExit)
{
if (spotHolding.IsShort && futureHolding.IsLong)
{
if (spotAskPrice <= futureBidPrice * _exitThresholdFactor)
{
shouldExit = true;
exitReason = "Profit Target/Convergence";
}
}
else if (spotHolding.IsLong && futureHolding.IsShort)
{
if (futureAskPrice <= spotBidPrice * _exitThresholdFactor)
{
shouldExit = true;
exitReason = "Profit Target/Convergence";
}
}
}
if (shouldExit)
{
LiquidatePair(pairKey, spotSymbol, futureSymbol, exitReason);
}
}
private void HandlePotentialTradeEntry(string pairKey, Symbol spotSymbol, Symbol futureSymbol, SecurityHolding spotHolding, SecurityHolding futureHolding, bool isMarketBroken, double spotBidPrice, double spotAskPrice, double futureBidPrice, double futureAskPrice, double spotMidPrice, double futureMidPrice, double multiplier)
{
if (isMarketBroken)
{
if (!_quarantinedPairs.ContainsKey(pairKey))
{
_quarantinedPairs[pairKey] = Time.Add(_quarantineDuration);
}
return;
}
if (spotHolding.Invested || futureHolding.Invested)
{
if (_knownDustPairs.Contains(pairKey)) return;
LiquidatePair(pairKey, spotSymbol, futureSymbol, "State Cleanup");
return;
}
if ((spotAskPrice / spotBidPrice - 1) > 0.01 || (futureAskPrice / futureBidPrice - 1) > 0.01)
{
return;
}
var spotSec = Securities[spotSymbol];
var futureSec = Securities[futureSymbol];
var portfolioValue = (double)Portfolio.TotalPortfolioValue;
var targetNotionalPerTrade = (portfolioValue * _totalStrategyAllocation * 0.5) / _maxConcurrentPairs;
var midFutureNotional = futureMidPrice * multiplier;
if (midFutureNotional <= 0) return;
var contractsQuantityRaw = targetNotionalPerTrade / midFutureNotional;
if (contractsQuantityRaw <= 0) return;
var spotLotSize = spotSec.SymbolProperties?.LotSize ?? 0m;
var futureLotSize = futureSec.SymbolProperties?.LotSize ?? 0m;
var potentialSpotQtyAbs = Math.Abs(contractsQuantityRaw * multiplier);
var potentialFutureQtyAbs = Math.Abs(contractsQuantityRaw);
if (spotLotSize > 0) potentialSpotQtyAbs = (double)(Math.Round((decimal)potentialSpotQtyAbs / spotLotSize, 8) * spotLotSize);
if (futureLotSize > 0) potentialFutureQtyAbs = (double)(Math.Round((decimal)potentialFutureQtyAbs / futureLotSize, 8) * futureLotSize);
if ((potentialSpotQtyAbs * spotMidPrice) < _minEntryOrderValue) return;
if (potentialSpotQtyAbs == 0 || potentialFutureQtyAbs == 0) return;
double finalSpotQty = 0.0;
double finalFutureQty = 0.0;
string entryDirection = null;
var maxAllowedSpreadFactor = 1.05;
if (spotBidPrice >= futureAskPrice * _baseEntryThresholdFactor)
{
if (spotBidPrice / futureAskPrice <= maxAllowedSpreadFactor)
{
finalSpotQty = -potentialSpotQtyAbs;
finalFutureQty = potentialFutureQtyAbs;
entryDirection = "Short Spot / Long Future";
}
}
else if (futureBidPrice >= spotAskPrice * _baseEntryThresholdFactorInv)
{
if (futureBidPrice > 0 && spotAskPrice / futureBidPrice >= (1 / maxAllowedSpreadFactor))
{
finalSpotQty = potentialSpotQtyAbs;
finalFutureQty = -potentialFutureQtyAbs;
entryDirection = "Long Spot / Short Future";
}
}
if (entryDirection != null)
{
var totalRequiredMargin = spotSec.BuyingPowerModel.GetInitialMarginRequirement(new InitialMarginParameters(spotSec, (decimal)finalSpotQty)).Value +
futureSec.BuyingPowerModel.GetInitialMarginRequirement(new InitialMarginParameters(futureSec, (decimal)finalFutureQty)).Value;
if (Portfolio.MarginRemaining >= totalRequiredMargin)
{
MarketOrder(spotSymbol, finalSpotQty);
MarketOrder(futureSymbol, finalFutureQty);
_activeTradePairs.Add(pairKey);
_pairEntryTimes[pairKey] = Time;
_pairInitialNotional[pairKey] = (decimal)Math.Abs(finalFutureQty * futureMidPrice * multiplier);
}
}
}
private void LiquidatePair(string pairKey, Symbol spotSymbol, Symbol futureSymbol, string reason = "Unknown")
{
Log($"Evaluating liquidation for pair {pairKey} for reason: {reason}");
if (spotSymbol != null && Portfolio[spotSymbol].Invested)
{
var spotHolding = Portfolio[spotSymbol];
if (Math.Abs(spotHolding.HoldingsValue) >= (decimal)_minLiquidationQuoteValue)
{
SetHoldings(spotSymbol, 0, tag: $"Close {pairKey} Spot ({reason})");
}
else
{
Log($"Spot position for {pairKey} is dust ({spotHolding.HoldingsValue:C}). Skipping liquidation order.");
_knownDustPairs.Add(pairKey);
}
}
if (futureSymbol != null && Portfolio[futureSymbol].Invested)
{
var futureHolding = Portfolio[futureSymbol];
var futureSecurity = Securities[futureSymbol];
decimal futureValue = Math.Abs(futureHolding.Quantity * futureSecurity.Price * (decimal)_multipliers[futureSymbol]);
if (futureValue >= (decimal)_minLiquidationQuoteValue)
{
SetHoldings(futureSymbol, 0, tag: $"Close {pairKey} Future ({reason})");
}
else
{
Log($"Future position for {pairKey} is dust ({futureValue:C}). Skipping liquidation order.");
_knownDustPairs.Add(pairKey);
}
}
if (_activeTradePairs.Contains(pairKey))
{
_activeTradePairs.Remove(pairKey);
_pairEntryTimes.Remove(pairKey);
_pairInitialNotional.Remove(pairKey);
}
}
private string GetBaseCurrency(Symbol symbol)
{
return symbol.Value.Replace(_futureQuoteCurrency, "");
}
private int GetDecimalPlaces(Security security)
{
try
{
var magnitude = security.SymbolProperties?.MinimumPriceVariation;
if (magnitude.HasValue && magnitude > 0)
{
return BitConverter.GetBytes(decimal.GetBits(magnitude.Value)[3])[2];
}
return 2;
}
catch (Exception)
{
return 2;
}
}
public override void OnOrderEvent(OrderEvent orderEvent)
{
if (orderEvent.Status == OrderStatus.Invalid)
{
var order = Transactions.GetOrderById(orderEvent.OrderId);
var orderTag = order?.Tag ?? "";
Error($"Order Invalid: ID:{orderEvent.OrderId} Symbol:{orderEvent.Symbol.Value}, Qty:{(order != null ? order.Quantity.ToString(CultureInfo.InvariantCulture) : "N/A")}, Status:{orderEvent.Status}, Tag:'{orderTag}'. Reason: {orderEvent.Message}.");
}
}
public override void OnEndOfAlgorithm()
{
Log($"Ending Algorithm. Final Portfolio Value: {Portfolio.TotalPortfolioValue:C}");
var pairsToLiquidate = new List<string>(_targetPairs);
foreach (var pairKey in pairsToLiquidate)
{
_spotSymbols.TryGetValue(pairKey, out var spotSymbol);
_futureSymbols.TryGetValue(pairKey, out var futureSymbol);
if ((spotSymbol != null && Portfolio[spotSymbol].Invested) || (futureSymbol != null && Portfolio[futureSymbol].Invested))
{
LiquidatePair(pairKey, spotSymbol, futureSymbol, "EndOfAlgorithm");
}
}
Log("OnEndOfAlgorithm finished.");
}
}
}