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.");
        }
    }
}