Fix realized pnl on backtest save + add tests (not all passing)

This commit is contained in:
2025-11-14 02:38:15 +07:00
parent 1f7d914625
commit 460a7bd559
34 changed files with 6012 additions and 500 deletions

View 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);
}
}