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; /// /// Tests for trading metrics calculation methods in TradingBox. /// Covers volume, P&L, win rate, and fee calculations. /// 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(); // 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 }; // 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 { 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(); // 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 { { 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 { { 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(); // 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> { new Tuple(0.001m, 50000m), // Open new Tuple(-0.001m, 52000m) // Close at profit }, TradeDirection.Long); var positions = new List { 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> { new Tuple(0.001m, 50000m), // Open new Tuple(-0.001m, 48000m) // Close at loss }, TradeDirection.Long); var positions = new List { 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(); // 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>(), TradeDirection.Long) { Realized = 100m }; var position2 = CreateTestPosition(); position2.ProfitAndLoss = new ProfitAndLoss(new List>(), TradeDirection.Long) { Realized = 50m }; var positions = new Dictionary { { 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(); // 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>(), TradeDirection.Long) { Net = 80m // After fees }; var position2 = CreateTestPosition(); position2.ProfitAndLoss = new ProfitAndLoss(new List>(), TradeDirection.Long) { Net = 40m }; var positions = new Dictionary { { 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(); // 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>(), TradeDirection.Long) { Realized = 100m }; var winningPosition2 = CreateTestPosition(); winningPosition2.ProfitAndLoss = new ProfitAndLoss(new List>(), TradeDirection.Long) { Realized = 50m }; var losingPosition = CreateTestPosition(); losingPosition.ProfitAndLoss = new ProfitAndLoss(new List>(), TradeDirection.Long) { Realized = -25m }; var positions = new Dictionary { { 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(); // 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 { { 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); } }