Fix realized pnl on backtest save + add tests (not all passing)
This commit is contained in:
559
src/Managing.Domain.IndicatorTests/TradingMetricsTests.cs
Normal file
559
src/Managing.Domain.IndicatorTests/TradingMetricsTests.cs
Normal file
@@ -0,0 +1,559 @@
|
||||
using FluentAssertions;
|
||||
using Managing.Common;
|
||||
using Managing.Domain.Accounts;
|
||||
using Managing.Domain.Candles;
|
||||
using Managing.Domain.Indicators;
|
||||
using Managing.Domain.MoneyManagements;
|
||||
using Managing.Domain.Scenarios;
|
||||
using Managing.Domain.Shared.Helpers;
|
||||
using Managing.Domain.Statistics;
|
||||
using Managing.Domain.Strategies;
|
||||
using Managing.Domain.Strategies.Base;
|
||||
using Managing.Domain.Trades;
|
||||
using Xunit;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for trading metrics calculation methods in TradingBox.
|
||||
/// Covers volume, P&L, win rate, and fee calculations.
|
||||
/// </summary>
|
||||
public class TradingMetricsTests
|
||||
{
|
||||
protected static readonly DateTime TestDate = new(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Test data builders
|
||||
protected static Candle CreateTestCandle(decimal open = 100m, decimal high = 110m, decimal low = 90m, decimal close = 105m,
|
||||
DateTime? date = null, Ticker ticker = Ticker.BTC, Timeframe timeframe = Timeframe.OneHour)
|
||||
{
|
||||
return new Candle
|
||||
{
|
||||
Open = open,
|
||||
High = high,
|
||||
Low = low,
|
||||
Close = close,
|
||||
Date = date ?? TestDate,
|
||||
Ticker = ticker,
|
||||
Timeframe = timeframe,
|
||||
Exchange = TradingExchanges.Binance,
|
||||
Volume = 1000
|
||||
};
|
||||
}
|
||||
|
||||
// Enhanced position builder for trading metrics tests
|
||||
protected static Position CreateTestPosition(decimal openPrice = 50000m, decimal quantity = 0.001m,
|
||||
TradeDirection direction = TradeDirection.Long, decimal leverage = 1m,
|
||||
decimal? stopLossPercentage = 0.02m, decimal? takeProfitPercentage = 0.04m,
|
||||
bool isClosed = false, decimal? closePrice = null, DateTime? closeDate = null)
|
||||
{
|
||||
var user = new Managing.Domain.Users.User { Id = 1, Name = "TestUser" };
|
||||
var moneyManagement = new LightMoneyManagement
|
||||
{
|
||||
Name = "TestMM",
|
||||
Timeframe = Timeframe.OneHour,
|
||||
StopLoss = stopLossPercentage ?? 0.02m,
|
||||
TakeProfit = takeProfitPercentage ?? 0.04m,
|
||||
Leverage = leverage
|
||||
};
|
||||
|
||||
var position = new Position(
|
||||
identifier: Guid.NewGuid(),
|
||||
accountId: 1,
|
||||
originDirection: direction,
|
||||
ticker: Ticker.BTC,
|
||||
moneyManagement: moneyManagement,
|
||||
initiator: PositionInitiator.User,
|
||||
date: TestDate,
|
||||
user: user
|
||||
);
|
||||
|
||||
// Set the Open trade
|
||||
position.Open = new Trade(
|
||||
date: TestDate,
|
||||
direction: direction,
|
||||
status: TradeStatus.Filled,
|
||||
tradeType: TradeType.Market,
|
||||
ticker: Ticker.BTC,
|
||||
quantity: quantity,
|
||||
price: openPrice,
|
||||
leverage: leverage,
|
||||
exchangeOrderId: Guid.NewGuid().ToString(),
|
||||
message: "Open position"
|
||||
);
|
||||
|
||||
// Calculate SL/TP prices based on direction
|
||||
decimal stopLossPrice, takeProfitPrice;
|
||||
|
||||
if (direction == TradeDirection.Long)
|
||||
{
|
||||
stopLossPrice = openPrice * (1 - (stopLossPercentage ?? 0.02m));
|
||||
takeProfitPrice = openPrice * (1 + (takeProfitPercentage ?? 0.04m));
|
||||
}
|
||||
else // Short
|
||||
{
|
||||
stopLossPrice = openPrice * (1 + (stopLossPercentage ?? 0.02m));
|
||||
takeProfitPrice = openPrice * (1 - (takeProfitPercentage ?? 0.04m));
|
||||
}
|
||||
|
||||
// Set the StopLoss trade
|
||||
position.StopLoss = new Trade(
|
||||
date: TestDate.AddMinutes(5),
|
||||
direction: direction == TradeDirection.Long ? TradeDirection.Short : TradeDirection.Long,
|
||||
status: isClosed && closePrice.HasValue ? TradeStatus.Filled : TradeStatus.PendingOpen,
|
||||
tradeType: TradeType.Market,
|
||||
ticker: Ticker.BTC,
|
||||
quantity: quantity,
|
||||
price: isClosed && closePrice.HasValue ? closePrice.Value : stopLossPrice,
|
||||
leverage: leverage,
|
||||
exchangeOrderId: Guid.NewGuid().ToString(),
|
||||
message: "Stop Loss"
|
||||
);
|
||||
|
||||
// Set the TakeProfit trade
|
||||
position.TakeProfit1 = new Trade(
|
||||
date: closeDate ?? TestDate.AddMinutes(10),
|
||||
direction: direction == TradeDirection.Long ? TradeDirection.Short : TradeDirection.Long,
|
||||
status: isClosed && closePrice.HasValue ? TradeStatus.Filled : TradeStatus.PendingOpen,
|
||||
tradeType: TradeType.Market,
|
||||
ticker: Ticker.BTC,
|
||||
quantity: quantity,
|
||||
price: isClosed && closePrice.HasValue ? closePrice.Value : takeProfitPrice,
|
||||
leverage: leverage,
|
||||
exchangeOrderId: Guid.NewGuid().ToString(),
|
||||
message: "Take Profit"
|
||||
);
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTotalVolumeTraded_WithEmptyPositions_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var positions = new List<Position>();
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetTotalVolumeTraded(positions);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTotalVolumeTraded_WithSinglePosition_CalculatesCorrectVolume()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 2m);
|
||||
var positions = new List<Position> { position };
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetTotalVolumeTraded(positions);
|
||||
|
||||
// Assert - Volume = (price * quantity * leverage) for both open and close
|
||||
var expectedVolume = (50000m * 0.001m * 2m) * 2; // Open and close volume
|
||||
result.Should().Be(expectedVolume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTotalVolumeTraded_WithMultiplePositions_SumsAllVolumes()
|
||||
{
|
||||
// Arrange
|
||||
var position1 = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m);
|
||||
var position2 = CreateTestPosition(openPrice: 60000m, quantity: 0.002m, leverage: 2m);
|
||||
var positions = new List<Position> { position1, position2 };
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetTotalVolumeTraded(positions);
|
||||
|
||||
// Assert
|
||||
var expectedVolume1 = (50000m * 0.001m * 1m) * 2; // Position 1
|
||||
var expectedVolume2 = (60000m * 0.002m * 2m) * 2; // Position 2
|
||||
var expectedTotal = expectedVolume1 + expectedVolume2;
|
||||
result.Should().Be(expectedTotal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLast24HVolumeTraded_WithEmptyPositions_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var positions = new Dictionary<Guid, Position>();
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetLast24HVolumeTraded(positions);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLast24HVolumeTraded_WithRecentTrades_IncludesRecentVolume()
|
||||
{
|
||||
// Arrange
|
||||
var recentPosition = CreateTestPosition();
|
||||
recentPosition.Open.Date = DateTime.UtcNow.AddHours(-12); // Within 24 hours
|
||||
var positions = new Dictionary<Guid, Position> { { recentPosition.Identifier, recentPosition } };
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetLast24HVolumeTraded(positions);
|
||||
|
||||
// Assert
|
||||
var expectedVolume = recentPosition.Open.Quantity * recentPosition.Open.Price;
|
||||
result.Should().Be(expectedVolume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLast24HVolumeTraded_WithOldTrades_ExcludesOldVolume()
|
||||
{
|
||||
// Arrange
|
||||
var oldPosition = CreateTestPosition();
|
||||
oldPosition.Open.Date = DateTime.UtcNow.AddHours(-48); // Outside 24 hours
|
||||
var positions = new Dictionary<Guid, Position> { { oldPosition.Identifier, oldPosition } };
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetLast24HVolumeTraded(positions);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWinLossCount_WithEmptyPositions_ReturnsZeros()
|
||||
{
|
||||
// Arrange
|
||||
var positions = new List<Position>();
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetWinLossCount(positions);
|
||||
|
||||
// Assert
|
||||
result.Wins.Should().Be(0);
|
||||
result.Losses.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWinLossCount_WithProfitablePositions_CountsWins()
|
||||
{
|
||||
// Arrange
|
||||
var winningPosition = CreateTestPosition();
|
||||
winningPosition.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>
|
||||
{
|
||||
new Tuple<decimal, decimal>(0.001m, 50000m), // Open
|
||||
new Tuple<decimal, decimal>(-0.001m, 52000m) // Close at profit
|
||||
}, TradeDirection.Long);
|
||||
var positions = new List<Position> { winningPosition };
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetWinLossCount(positions);
|
||||
|
||||
// Assert
|
||||
result.Wins.Should().Be(1);
|
||||
result.Losses.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWinLossCount_WithLosingPositions_CountsLosses()
|
||||
{
|
||||
// Arrange
|
||||
var losingPosition = CreateTestPosition();
|
||||
losingPosition.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>
|
||||
{
|
||||
new Tuple<decimal, decimal>(0.001m, 50000m), // Open
|
||||
new Tuple<decimal, decimal>(-0.001m, 48000m) // Close at loss
|
||||
}, TradeDirection.Long);
|
||||
var positions = new List<Position> { losingPosition };
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetWinLossCount(positions);
|
||||
|
||||
// Assert
|
||||
result.Wins.Should().Be(0);
|
||||
result.Losses.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTotalRealizedPnL_WithEmptyPositions_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var positions = new Dictionary<Guid, Position>();
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetTotalRealizedPnL(positions);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTotalRealizedPnL_WithValidPositions_SumsRealizedPnL()
|
||||
{
|
||||
// Arrange
|
||||
var position1 = CreateTestPosition();
|
||||
position1.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
||||
{
|
||||
Realized = 100m
|
||||
};
|
||||
|
||||
var position2 = CreateTestPosition();
|
||||
position2.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
||||
{
|
||||
Realized = 50m
|
||||
};
|
||||
|
||||
var positions = new Dictionary<Guid, Position>
|
||||
{
|
||||
{ position1.Identifier, position1 },
|
||||
{ position2.Identifier, position2 }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetTotalRealizedPnL(positions);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(150m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTotalNetPnL_WithEmptyPositions_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var positions = new Dictionary<Guid, Position>();
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetTotalNetPnL(positions);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTotalNetPnL_WithValidPositions_SumsNetPnL()
|
||||
{
|
||||
// Arrange
|
||||
var position1 = CreateTestPosition();
|
||||
position1.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
||||
{
|
||||
Net = 80m // After fees
|
||||
};
|
||||
|
||||
var position2 = CreateTestPosition();
|
||||
position2.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
||||
{
|
||||
Net = 40m
|
||||
};
|
||||
|
||||
var positions = new Dictionary<Guid, Position>
|
||||
{
|
||||
{ position1.Identifier, position1 },
|
||||
{ position2.Identifier, position2 }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetTotalNetPnL(positions);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(120m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWinRate_WithEmptyPositions_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var positions = new Dictionary<Guid, Position>();
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetWinRate(positions);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetWinRate_WithProfitablePositions_CalculatesPercentage()
|
||||
{
|
||||
// Arrange
|
||||
var winningPosition1 = CreateTestPosition();
|
||||
winningPosition1.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
||||
{
|
||||
Realized = 100m
|
||||
};
|
||||
|
||||
var winningPosition2 = CreateTestPosition();
|
||||
winningPosition2.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
||||
{
|
||||
Realized = 50m
|
||||
};
|
||||
|
||||
var losingPosition = CreateTestPosition();
|
||||
losingPosition.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
||||
{
|
||||
Realized = -25m
|
||||
};
|
||||
|
||||
var positions = new Dictionary<Guid, Position>
|
||||
{
|
||||
{ winningPosition1.Identifier, winningPosition1 },
|
||||
{ winningPosition2.Identifier, winningPosition2 },
|
||||
{ losingPosition.Identifier, losingPosition }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetWinRate(positions);
|
||||
|
||||
// Assert - 2 wins out of 3 positions = 66%
|
||||
result.Should().Be(67); // Rounded up
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTotalFees_WithEmptyPositions_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var positions = new Dictionary<Guid, Position>();
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetTotalFees(positions);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTotalFees_WithValidPositions_SumsAllFees()
|
||||
{
|
||||
// Arrange
|
||||
var position1 = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m);
|
||||
var position2 = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m);
|
||||
|
||||
var positions = new Dictionary<Guid, Position>
|
||||
{
|
||||
{ position1.Identifier, position1 },
|
||||
{ position2.Identifier, position2 }
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetTotalFees(positions);
|
||||
|
||||
// Assert - Each position has fees, so total should be sum of both
|
||||
result.Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePositionFees_WithValidPosition_CalculatesFees()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m);
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculatePositionFees(position);
|
||||
|
||||
// Assert
|
||||
result.Should().BeGreaterThan(0);
|
||||
// UI fees should be calculated based on position size
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePositionFeesBreakdown_ReturnsUiAndGasFees()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m);
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculatePositionFeesBreakdown(position);
|
||||
|
||||
// Assert
|
||||
result.uiFees.Should().BeGreaterThan(0);
|
||||
result.gasFees.Should().Be(Constants.GMX.Config.GasFeePerTransaction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateOpeningUiFees_WithPositionSize_CalculatesCorrectFee()
|
||||
{
|
||||
// Arrange
|
||||
var positionSizeUsd = 1000m;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculateOpeningUiFees(positionSizeUsd);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(positionSizeUsd * Constants.GMX.Config.UiFeeRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateClosingUiFees_WithPositionSize_CalculatesCorrectFee()
|
||||
{
|
||||
// Arrange
|
||||
var positionSizeUsd = 1000m;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculateClosingUiFees(positionSizeUsd);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(positionSizeUsd * Constants.GMX.Config.UiFeeRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateOpeningGasFees_ReturnsFixedAmount()
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.CalculateOpeningGasFees();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(Constants.GMX.Config.GasFeePerTransaction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVolumeForPosition_WithOpenPosition_IncludesOpenVolume()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m);
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetVolumeForPosition(position);
|
||||
|
||||
// Assert
|
||||
var expectedVolume = 50000m * 0.001m * 1m; // price * quantity * leverage
|
||||
result.Should().Be(expectedVolume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVolumeForPosition_WithClosedPosition_IncludesAllTradeVolumes()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m, isClosed: true, closePrice: 52000m);
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetVolumeForPosition(position);
|
||||
|
||||
// Assert
|
||||
var openVolume = 50000m * 0.001m * 1m;
|
||||
var closeVolume = 52000m * 0.001m * 1m;
|
||||
var expectedTotal = openVolume + closeVolume;
|
||||
result.Should().Be(expectedTotal);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1000, 0.1)] // 0.1% fee rate
|
||||
[InlineData(10000, 1.0)] // 1.0 fee
|
||||
[InlineData(100000, 10.0)] // 10.0 fee
|
||||
public void CalculateOpeningUiFees_WithDifferentSizes_CalculatesProportionally(decimal positionSize, decimal expectedFee)
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.CalculateOpeningUiFees(positionSize);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedFee);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TradeDirection.Long, 50000, 0.001, 1, 50)] // Long position volume
|
||||
[InlineData(TradeDirection.Short, 50000, 0.001, 2, 100)] // Short position with leverage
|
||||
public void GetVolumeForPosition_CalculatesCorrectVolume(TradeDirection direction, decimal price, decimal quantity, decimal leverage, decimal expectedVolume)
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(openPrice: price, quantity: quantity, leverage: leverage, direction: direction);
|
||||
|
||||
// Act
|
||||
var result = TradingBox.GetVolumeForPosition(position);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedVolume);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user