diff --git a/Common/Statistics/TradeBuilder.cs b/Common/Statistics/TradeBuilder.cs index 48e525cbbfac..48993133af33 100644 --- a/Common/Statistics/TradeBuilder.cs +++ b/Common/Statistics/TradeBuilder.cs @@ -17,6 +17,7 @@ using QuantConnect.Interfaces; using QuantConnect.Orders; using QuantConnect.Securities; +using QuantConnect.Securities.CryptoFuture; using QuantConnect.Util; using System; using System.Collections.Generic; @@ -163,7 +164,8 @@ public void SetMarketPrice(Symbol symbol, decimal price) { var tradeState = position.PendingTrades[i]; var trade = tradeState.Trade; - var currentProfit = trade.Direction == TradeDirection.Long ? price - trade.EntryPrice : trade.EntryPrice - price; + var currentProfit = (GetProfitPrice(symbol, price) - GetProfitPrice(symbol, trade.EntryPrice)) + * (trade.Direction == TradeDirection.Long ? +1 : -1); tradeState.UpdateDrawdown(currentProfit); } } @@ -228,6 +230,11 @@ public void ProcessFill(OrderEvent fill, decimal feeInAccountCurrency, decimal multiplier = 1.0m) { + if (TryGetInverseCryptoFuture(fill.Symbol, out var cryptoFuture)) + { + securityConversionRate = cryptoFuture.BaseCurrency.ConversionRate; + } + // If we have multiple fills per order, we assign the order fee only to its first fill // to avoid counting the same order fee multiple times. var orderFee = 0m; @@ -314,6 +321,10 @@ private void ProcessFillUsingFillToFill(OrderEvent fill, decimal orderFee, decim var tradeState = position.PendingTrades[index]; var trade = tradeState.Trade; var absoluteUnexecutedQuantity = fill.AbsoluteFillQuantity - Math.Abs(totalExecutedQuantity); + var entryValue = GetProfitPrice(fill.Symbol, trade.EntryPrice); + var exitValue = GetProfitPrice(fill.Symbol, fill.FillPrice); + var minValue = GetProfitPrice(fill.Symbol, position.MinPrice); + var maxValue = GetProfitPrice(fill.Symbol, position.MaxPrice); if (absoluteUnexecutedQuantity >= trade.Quantity) { @@ -325,11 +336,11 @@ private void ProcessFillUsingFillToFill(OrderEvent fill, decimal orderFee, decim trade.ExitTime = fill.UtcTime; trade.ExitPrice = fill.FillPrice; - trade.ProfitLoss = Math.Round((trade.ExitPrice - trade.EntryPrice) * trade.Quantity * (trade.Direction == TradeDirection.Long ? +1 : -1) * conversionRate * multiplier, 2); + trade.ProfitLoss = Math.Round((exitValue - entryValue) * trade.Quantity * (trade.Direction == TradeDirection.Long ? +1 : -1) * conversionRate * multiplier, 2); // if closing multiple trades with the same order, assign order fee only once trade.TotalFees += orderFeeAssigned ? 0 : orderFee; - trade.MAE = Math.Round((trade.Direction == TradeDirection.Long ? position.MinPrice - trade.EntryPrice : trade.EntryPrice - position.MaxPrice) * trade.Quantity * conversionRate * multiplier, 2); - trade.MFE = Math.Round((trade.Direction == TradeDirection.Long ? position.MaxPrice - trade.EntryPrice : trade.EntryPrice - position.MinPrice) * trade.Quantity * conversionRate * multiplier, 2); + trade.MAE = Math.Round((trade.Direction == TradeDirection.Long ? minValue - entryValue : entryValue - maxValue) * trade.Quantity * conversionRate * multiplier, 2); + trade.MFE = Math.Round((trade.Direction == TradeDirection.Long ? maxValue - entryValue : entryValue - minValue) * trade.Quantity * conversionRate * multiplier, 2); trade.EndTradeDrawdown = Math.Round(tradeState.MaxDrawdown * trade.Quantity * conversionRate * multiplier, 2); AddNewTrade(trade, fill); @@ -348,10 +359,10 @@ private void ProcessFillUsingFillToFill(OrderEvent fill, decimal orderFee, decim Quantity = absoluteUnexecutedQuantity, ExitTime = fill.UtcTime, ExitPrice = fill.FillPrice, - ProfitLoss = Math.Round((fill.FillPrice - trade.EntryPrice) * absoluteUnexecutedQuantity * (trade.Direction == TradeDirection.Long ? +1 : -1) * conversionRate * multiplier, 2), + ProfitLoss = Math.Round((exitValue - entryValue) * absoluteUnexecutedQuantity * (trade.Direction == TradeDirection.Long ? +1 : -1) * conversionRate * multiplier, 2), TotalFees = trade.TotalFees + (orderFeeAssigned ? 0 : orderFee), - MAE = Math.Round((trade.Direction == TradeDirection.Long ? position.MinPrice - trade.EntryPrice : trade.EntryPrice - position.MaxPrice) * absoluteUnexecutedQuantity * conversionRate * multiplier, 2), - MFE = Math.Round((trade.Direction == TradeDirection.Long ? position.MaxPrice - trade.EntryPrice : trade.EntryPrice - position.MinPrice) * absoluteUnexecutedQuantity * conversionRate * multiplier, 2), + MAE = Math.Round((trade.Direction == TradeDirection.Long ? minValue - entryValue : entryValue - maxValue) * absoluteUnexecutedQuantity * conversionRate * multiplier, 2), + MFE = Math.Round((trade.Direction == TradeDirection.Long ? maxValue - entryValue : entryValue - minValue) * absoluteUnexecutedQuantity * conversionRate * multiplier, 2), EndTradeDrawdown = Math.Round(tradeState.MaxDrawdown * absoluteUnexecutedQuantity * conversionRate * multiplier, 2), OrderIds = new HashSet([..trade.OrderIds, fill.OrderId]) }; @@ -436,22 +447,27 @@ private void ProcessFillUsingFlatToFlat(OrderEvent fill, decimal orderFee, decim var totalExitQuantity = 0m; var entryAveragePrice = 0m; var exitAveragePrice = 0m; + var entryAverageValue = 0m; + var exitAverageValue = 0m; var relatedOrderIds = new HashSet(); while (position.PendingFills.Count > 0) { var currentFill = position.PendingFills[index]; + var currentValue = GetProfitPrice(fill.Symbol, currentFill.FillPrice); if (Math.Sign(currentFill.FillQuantity) != Math.Sign(fill.FillQuantity)) { // entry totalEntryQuantity += currentFill.FillQuantity; entryAveragePrice += (currentFill.FillPrice - entryAveragePrice) * currentFill.FillQuantity / totalEntryQuantity; + entryAverageValue += (currentValue - entryAverageValue) * currentFill.FillQuantity / totalEntryQuantity; } else { // exit totalExitQuantity += currentFill.FillQuantity; exitAveragePrice += (currentFill.FillPrice - exitAveragePrice) * currentFill.FillQuantity / totalExitQuantity; + exitAverageValue += (currentValue - exitAverageValue) * currentFill.FillQuantity / totalExitQuantity; } relatedOrderIds.Add(currentFill.OrderId); position.PendingFills.RemoveAt(index); @@ -469,7 +485,7 @@ private void ProcessFillUsingFlatToFlat(OrderEvent fill, decimal orderFee, decim Quantity = Math.Abs(totalEntryQuantity), ExitTime = fill.UtcTime, ExitPrice = exitAveragePrice, - ProfitLoss = Math.Round((exitAveragePrice - entryAveragePrice) * Math.Abs(totalEntryQuantity) * Math.Sign(totalEntryQuantity) * conversionRate * multiplier, 2), + ProfitLoss = Math.Round((exitAverageValue - entryAverageValue) * Math.Abs(totalEntryQuantity) * Math.Sign(totalEntryQuantity) * conversionRate * multiplier, 2), TotalFees = position.TotalFees, OrderIds = relatedOrderIds // MAE, MFE, EndTradeDrawdown are zero for FlatToFlat grouping method. @@ -537,12 +553,14 @@ private void ProcessFillUsingFlatToReduced(OrderEvent fill, decimal orderFee, de var entryTime = position.PendingFills[index].UtcTime; var totalExecutedQuantity = 0m; var entryPrice = 0m; + var entryValue = 0m; position.TotalFees += orderFee; var relatedOrderIds = new HashSet { fill.OrderId }; while (position.PendingFills.Count > 0 && Math.Abs(totalExecutedQuantity) < fill.AbsoluteFillQuantity) { var currentFill = position.PendingFills[index]; + var currentValue = GetProfitPrice(fill.Symbol, currentFill.FillPrice); var absoluteUnexecutedQuantity = fill.AbsoluteFillQuantity - Math.Abs(totalExecutedQuantity); if (absoluteUnexecutedQuantity >= Math.Abs(currentFill.FillQuantity)) { @@ -551,6 +569,7 @@ private void ProcessFillUsingFlatToReduced(OrderEvent fill, decimal orderFee, de totalExecutedQuantity -= currentFill.FillQuantity; entryPrice -= (currentFill.FillPrice - entryPrice) * currentFill.FillQuantity / totalExecutedQuantity; + entryValue -= (currentValue - entryValue) * currentFill.FillQuantity / totalExecutedQuantity; position.PendingFills.RemoveAt(index); if (_matchingMethod == FillMatchingMethod.LIFO && index > 0) index--; @@ -560,6 +579,7 @@ private void ProcessFillUsingFlatToReduced(OrderEvent fill, decimal orderFee, de var executedQuantity = absoluteUnexecutedQuantity * Math.Sign(fill.FillQuantity); totalExecutedQuantity += executedQuantity; entryPrice += (currentFill.FillPrice - entryPrice) * executedQuantity / totalExecutedQuantity; + entryValue += (currentValue - entryValue) * executedQuantity / totalExecutedQuantity; currentFill.FillQuantity += executedQuantity; } relatedOrderIds.Add(currentFill.OrderId); @@ -575,7 +595,7 @@ private void ProcessFillUsingFlatToReduced(OrderEvent fill, decimal orderFee, de Quantity = Math.Abs(totalExecutedQuantity), ExitTime = fill.UtcTime, ExitPrice = fill.FillPrice, - ProfitLoss = Math.Round((fill.FillPrice - entryPrice) * Math.Abs(totalExecutedQuantity) * Math.Sign(-totalExecutedQuantity) * conversionRate * multiplier, 2), + ProfitLoss = Math.Round((GetProfitPrice(fill.Symbol, fill.FillPrice) - entryValue) * Math.Abs(totalExecutedQuantity) * Math.Sign(-totalExecutedQuantity) * conversionRate * multiplier, 2), TotalFees = position.TotalFees, OrderIds = relatedOrderIds @@ -604,6 +624,23 @@ private void ProcessFillUsingFlatToReduced(OrderEvent fill, decimal orderFee, de } } + private decimal GetProfitPrice(Symbol symbol, decimal price) + { + // Negated reciprocal prices give inverse contracts the same profit direction as linear contracts. + return TryGetInverseCryptoFuture(symbol, out _) ? -1m / price : price; + } + + private bool TryGetInverseCryptoFuture(Symbol symbol, out CryptoFuture cryptoFuture) + { + cryptoFuture = null; + if (symbol.SecurityType == SecurityType.CryptoFuture && symbol.ID.Market != Market.DYDX && + _securities != null && _securities.TryGetValue(symbol, out var security)) + { + cryptoFuture = security as CryptoFuture; + } + return cryptoFuture != null && cryptoFuture.IsCryptoCoinFuture(); + } + /// /// Adds a trade to the list of closed trades, capping the total number only in live mode /// diff --git a/Tests/Common/Statistics/TradeBuilderTests.cs b/Tests/Common/Statistics/TradeBuilderTests.cs index 6f4590100e4c..7c769cd4a179 100644 --- a/Tests/Common/Statistics/TradeBuilderTests.cs +++ b/Tests/Common/Statistics/TradeBuilderTests.cs @@ -21,8 +21,10 @@ using QuantConnect.Orders; using QuantConnect.Orders.Fees; using QuantConnect.Securities; +using QuantConnect.Securities.CryptoFuture; using QuantConnect.Securities.Option; using QuantConnect.Statistics; +using QuantConnect.Tests.Engine.DataFeeds; namespace QuantConnect.Tests.Common.Statistics { @@ -40,6 +42,119 @@ public void SetUp() _securityManager = new SecurityManager(new TimeKeeper(_startTime)); } + [TestCaseSource(nameof(CryptoFutureTradeCases))] + public void CryptoFutureTradeProfitLoss(string ticker, string market, decimal quantity, decimal expectedProfit, + FillGroupingMethod groupingMethod, int direction) + { + var builder = CreateCryptoFutureTradeBuilder(ticker, groupingMethod, FillMatchingMethod.FIFO, out var security, market); + var rate = security.QuoteCurrency.ConversionRate; + var multiplier = security.SymbolProperties.ContractMultiplier; + security.BaseCurrency.ConversionRate = 40000m; + builder.ProcessFill(new OrderEvent(1, security.Symbol, _startTime, OrderStatus.Filled, + direction > 0 ? OrderDirection.Buy : OrderDirection.Sell, 40000m, direction * quantity, _orderFee), + rate, 1m, multiplier); + security.BaseCurrency.ConversionRate = 44000m; + builder.ProcessFill(new OrderEvent(2, security.Symbol, _startTime.AddHours(1), OrderStatus.Filled, + direction > 0 ? OrderDirection.Sell : OrderDirection.Buy, 44000m, -direction * quantity, _orderFee), + rate, 1m, multiplier); + + var trade = builder.ClosedTrades[0]; + Assert.AreEqual(1, builder.ClosedTrades.Count); + Assert.AreEqual(direction * expectedProfit, trade.ProfitLoss); + Assert.AreEqual(40000m, trade.EntryPrice); + Assert.AreEqual(44000m, trade.ExitPrice); + Assert.AreEqual(quantity, trade.Quantity); + Assert.AreEqual(2m, trade.TotalFees); + Assert.AreEqual(direction > 0, trade.IsWin); + Assert.IsFalse(builder.HasOpenPosition(security.Symbol)); + } + + [Test] + public void InverseCryptoFutureExcursions([Values(-1, 1)] int direction, + [Values(1.0, 0.8)] double accountRate) + { + var builder = CreateCryptoFutureTradeBuilder("BTCUSD", FillGroupingMethod.FillToFill, FillMatchingMethod.FIFO, + out var security, accountCurrency: accountRate == 1.0 ? Currencies.USD : "EUR"); + var adverse = direction > 0 ? -1222.22m : -2200m; + var favorable = direction > 0 ? 2200m : 1222.22m; + var drawdown = direction > 0 ? 3422.22m : 2222.22m; + var rate = (decimal)accountRate; + security.BaseCurrency.ConversionRate = 44000m * rate; + security.QuoteCurrency.ConversionRate = rate; + builder.ProcessFill(new OrderEvent(1, security.Symbol, _startTime, OrderStatus.Filled, + direction > 0 ? OrderDirection.Buy : OrderDirection.Sell, 40000m, direction * 100m, OrderFee.Zero), + rate, 0m, 100m); + builder.SetMarketPrice(security.Symbol, 50000m); + builder.SetMarketPrice(security.Symbol, 36000m); + builder.ProcessFill(new OrderEvent(2, security.Symbol, _startTime.AddHours(1), OrderStatus.Filled, + direction > 0 ? OrderDirection.Sell : OrderDirection.Buy, 44000m, -direction * 100m, OrderFee.Zero), + rate, 0m, 100m); + + var trade = builder.ClosedTrades[0]; + Assert.AreEqual(direction * 1000m * rate, trade.ProfitLoss); + Assert.AreEqual(Math.Round(adverse * rate, 2), trade.MAE); + Assert.AreEqual(Math.Round(favorable * rate, 2), trade.MFE); + Assert.AreEqual(Math.Round(drawdown * rate, 2), trade.EndTradeDrawdown); + } + + [Test] + public void InverseCryptoFutureScaleInAndPartialExit([Values] FillGroupingMethod groupingMethod, + [Values] FillMatchingMethod matchingMethod, [Values(-1, 1)] int direction) + { + var builder = CreateCryptoFutureTradeBuilder("BTCUSD", groupingMethod, matchingMethod, out var security); + var prices = new[] { 40000m, 50000m, 44000m, 48000m }; + for (var i = 0; i < prices.Length; i++) + { + var quantity = (i < 2 ? 100m : -100m) * direction; + builder.ProcessFill(new OrderEvent(i + 1, security.Symbol, _startTime.AddMinutes(i), OrderStatus.Filled, + quantity > 0 ? OrderDirection.Buy : OrderDirection.Sell, prices[i], quantity, OrderFee.Zero), + 1m, 0m, 100m); + } + + var totalProfit = 0m; + foreach (var trade in builder.ClosedTrades) + { + totalProfit += trade.ProfitLoss; + } + Assert.AreEqual(direction * 633.33m, totalProfit); + Assert.IsFalse(builder.HasOpenPosition(security.Symbol)); + if (groupingMethod == FillGroupingMethod.FlatToFlat) + { + Assert.AreEqual(45000m, builder.ClosedTrades[0].EntryPrice); + Assert.AreEqual(46000m, builder.ClosedTrades[0].ExitPrice); + } + } + + private static IEnumerable CryptoFutureTradeCases + { + get + { + foreach (var grouping in Enum.GetValues()) + { + foreach (var direction in new[] { -1, 1 }) + { + yield return new TestCaseData("BTCUSD", Market.Binance, 100m, 1000m, grouping, direction); + yield return new TestCaseData("BTCUSDT", Market.Binance, 1m, 4000m, grouping, direction); + yield return new TestCaseData("BTCUSD", Market.DYDX, 1m, 4000m, grouping, direction); + } + } + } + } + + private static TradeBuilder CreateCryptoFutureTradeBuilder(string ticker, FillGroupingMethod groupingMethod, + FillMatchingMethod matchingMethod, out CryptoFuture security, string market = Market.Binance, + string accountCurrency = Currencies.USD) + { + var algorithm = new AlgorithmStub(); + algorithm.SetAccountCurrency(accountCurrency); + security = algorithm.AddCryptoFuture(ticker, market: market); + security.BaseCurrency.ConversionRate = 44000m; + security.QuoteCurrency.ConversionRate = 1m; + var builder = new TradeBuilder(groupingMethod, matchingMethod); + builder.SetSecurityManager(algorithm.Securities); + return builder; + } + [Test] public void AllInAllOutLong( [Values] FillGroupingMethod groupingMethod,