Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 46 additions & 9 deletions Common/Statistics/TradeBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
{
Expand All @@ -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);
Expand All @@ -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<int>([..trade.OrderIds, fill.OrderId])
};
Expand Down Expand Up @@ -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<int>();

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);
Expand All @@ -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.
Expand Down Expand Up @@ -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<int> { 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))
{
Expand All @@ -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--;
Expand All @@ -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);
Expand All @@ -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

Expand Down Expand Up @@ -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();
}

/// <summary>
/// Adds a trade to the list of closed trades, capping the total number only in live mode
/// </summary>
Expand Down
115 changes: 115 additions & 0 deletions Tests/Common/Statistics/TradeBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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<TestCaseData> CryptoFutureTradeCases
{
get
{
foreach (var grouping in Enum.GetValues<FillGroupingMethod>())
{
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,
Expand Down