using FluentAssertions; using Managing.Domain.Shared.Helpers; using Managing.Domain.Trades; using Managing.Domain.Users; using Xunit; using static Managing.Common.Enums; namespace Managing.Domain.Tests; /// /// Tests for Platform Summary Metrics calculations in TradingBox /// public class PlatformSummaryMetricsTests { protected static readonly DateTime TestDate = new(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); // Test data builders for Position with different statuses and scenarios // Main Position builder with status control 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, PositionStatus positionStatus = PositionStatus.Filled, PositionInitiator initiator = PositionInitiator.User, bool includeTrades = true) { var user = new 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: initiator, date: TestDate, user: user ); // Override the status set by constructor position.Status = positionStatus; if (includeTrades) { // Set the Open trade position.Open = new Trade( date: TestDate, direction: direction, status: positionStatus == PositionStatus.New ? TradeStatus.PendingOpen : 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 with status based on position status TradeStatus slTpStatus = positionStatus == PositionStatus.Finished ? TradeStatus.Filled : positionStatus == PositionStatus.Filled ? TradeStatus.PendingOpen : TradeStatus.PendingOpen; position.StopLoss = new Trade( date: TestDate.AddMinutes(5), direction: direction == TradeDirection.Long ? TradeDirection.Short : TradeDirection.Long, status: slTpStatus, tradeType: TradeType.Market, ticker: Ticker.BTC, quantity: quantity, price: stopLossPrice, leverage: leverage, exchangeOrderId: Guid.NewGuid().ToString(), message: "Stop Loss" ); // Set the TakeProfit trade position.TakeProfit1 = new Trade( date: TestDate.AddMinutes(10), direction: direction == TradeDirection.Long ? TradeDirection.Short : TradeDirection.Long, status: slTpStatus, tradeType: TradeType.Market, ticker: Ticker.BTC, quantity: quantity, price: takeProfitPrice, leverage: leverage, exchangeOrderId: Guid.NewGuid().ToString(), message: "Take Profit" ); } return position; } // Filled position (active trading position) protected static Position CreateFilledPosition(decimal openPrice = 50000m, decimal quantity = 0.001m, TradeDirection direction = TradeDirection.Long, decimal leverage = 1m, decimal? stopLossPercentage = 0.02m, decimal? takeProfitPercentage = 0.04m) { return CreateTestPosition(openPrice, quantity, direction, leverage, stopLossPercentage, takeProfitPercentage, PositionStatus.Filled, includeTrades: true); } // Finished position (closed, profit/loss realized) protected static Position CreateFinishedPosition(decimal openPrice = 50000m, decimal quantity = 0.001m, TradeDirection direction = TradeDirection.Long, decimal leverage = 1m, decimal closePrice = 51000m, bool closedBySL = false) { var position = CreateTestPosition(openPrice, quantity, direction, leverage, positionStatus: PositionStatus.Finished, includeTrades: true); // Update open trade to be closed position.Open.Status = TradeStatus.Filled; // Determine which trade closed the position if (closedBySL) { position.StopLoss.Status = TradeStatus.Filled; position.StopLoss.Date = TestDate.AddMinutes(30); position.TakeProfit1.Status = TradeStatus.Cancelled; } else { position.TakeProfit1.Status = TradeStatus.Filled; position.TakeProfit1.Date = TestDate.AddMinutes(30); position.StopLoss.Status = TradeStatus.Cancelled; } return position; } // Canceled position protected static Position CreateCanceledPosition(decimal openPrice = 50000m, decimal quantity = 0.001m, TradeDirection direction = TradeDirection.Long, decimal leverage = 1m) { var position = CreateTestPosition(openPrice, quantity, direction, leverage, positionStatus: PositionStatus.Canceled, includeTrades: true); position.Open.Status = TradeStatus.Cancelled; position.StopLoss.Status = TradeStatus.Cancelled; position.TakeProfit1.Status = TradeStatus.Cancelled; return position; } // Rejected position protected static Position CreateRejectedPosition(decimal openPrice = 50000m, decimal quantity = 0.001m, TradeDirection direction = TradeDirection.Long, decimal leverage = 1m) { var position = CreateTestPosition(openPrice, quantity, direction, leverage, positionStatus: PositionStatus.Rejected, includeTrades: true); position.Open.Status = TradeStatus.Cancelled; position.StopLoss.Status = TradeStatus.Cancelled; position.TakeProfit1.Status = TradeStatus.Cancelled; return position; } [Fact] public void CalculatePlatformSummaryMetrics_WithEmptyPositions_ReturnsEmptyMetrics() { // Arrange var positions = new List(); // Act var result = TradingBox.CalculatePlatformSummaryMetrics(positions); // Assert result.TotalPlatformVolume.Should().Be(0); result.TotalPlatformFees.Should().Be(0); result.TotalPlatformPnL.Should().Be(0); result.NetPnL.Should().Be(0); result.OpenInterest.Should().Be(0); result.TotalLifetimePositionCount.Should().Be(0); result.VolumeByAsset.Should().BeEmpty(); result.PositionCountByAsset.Should().BeEmpty(); result.PositionCountByDirection.Should().BeEmpty(); } [Fact] public void CalculatePlatformSummaryMetrics_WithNullPositions_ReturnsEmptyMetrics() { // Act var result = TradingBox.CalculatePlatformSummaryMetrics(null); // Assert result.TotalPlatformVolume.Should().Be(0); result.TotalPlatformFees.Should().Be(0); result.TotalPlatformPnL.Should().Be(0); result.NetPnL.Should().Be(0); result.OpenInterest.Should().Be(0); result.TotalLifetimePositionCount.Should().Be(0); result.VolumeByAsset.Should().BeEmpty(); result.PositionCountByAsset.Should().BeEmpty(); result.PositionCountByDirection.Should().BeEmpty(); } [Fact] public void CalculatePlatformSummaryMetrics_WithSingleFinishedPosition_CalculatesCorrectMetrics() { // Arrange var position = CreateFinishedPosition( openPrice: 50000m, quantity: 0.1m, direction: TradeDirection.Long, leverage: 2m, closePrice: 51000m, closedBySL: false ); // Calculate expected values - use the actual TP1 price from the position var expectedOpenVolume = 50000m * 0.1m * 2m; // Open volume var expectedCloseVolume = position.TakeProfit1.Price * 0.1m * 2m; // Close volume using actual TP1 price var expectedVolume = expectedOpenVolume + expectedCloseVolume; var expectedFees = position.CalculateTotalFees(); // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert result.TotalPlatformVolume.Should().Be(expectedVolume); result.TotalPlatformFees.Should().Be(expectedFees); result.TotalLifetimePositionCount.Should().Be(1); result.OpenInterest.Should().Be(0); // Position is closed result.VolumeByAsset.Should().ContainKey("BTC").WhoseValue.Should().Be(expectedVolume); result.PositionCountByAsset.Should().ContainKey("BTC").WhoseValue.Should().Be(1); result.PositionCountByDirection.Should().BeEmpty(); // No open positions } [Fact] public void CalculatePlatformSummaryMetrics_WithMultiplePositions_CalculatesAggregatedMetrics() { // Arrange var positions = new List { CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 2m, 51000m, false), CreateFinishedPosition(40000m, 0.2m, TradeDirection.Short, 1m, 39000m, false), CreateFilledPosition(60000m, 0.05m, TradeDirection.Long, 3m) // Open position }; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(positions); // Assert result.TotalLifetimePositionCount.Should().Be(3); result.VolumeByAsset.Should().ContainKey("BTC"); result.PositionCountByAsset.Should().ContainKey("BTC").WhoseValue.Should().Be(3); result.PositionCountByDirection.Should().ContainKey(TradeDirection.Long).WhoseValue.Should().Be(1); // Only the open position result.OpenInterest.Should().Be(60000m * 0.05m * 3m); // Only from the open position: price * quantity * leverage = 9000 } [Fact] public void CalculatePlatformSummaryMetrics_WithPreviousVolume_PreventsVolumeDecrease() { // Arrange var position = CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 51000m, false); var previousVolume = 100000m; // Higher than calculated volume // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }, previousVolume); // Assert result.TotalPlatformVolume.Should().Be(previousVolume); // Should not decrease } [Fact] public void CalculatePlatformSummaryMetrics_WithInvalidPositions_ExcludesThemFromCalculations() { // Arrange var positions = new List { CreateFinishedPosition(), // Valid position CreateCanceledPosition(), // Invalid position CreateRejectedPosition() // Invalid position }; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(positions); // Assert result.TotalLifetimePositionCount.Should().Be(1); // Only the finished position counts } [Fact] public void CalculatePlatformSummaryMetrics_WithMultipleAssets_CalculatesAssetBreakdowns() { // Arrange - Create positions for different assets var btcPosition = CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 51000m, false); var ethPosition = CreateFinishedPosition(3000m, 1m, TradeDirection.Short, 1m, 2900m, false); // Manually set different tickers (since CreateFinishedPosition defaults to BTC) ethPosition.Ticker = Ticker.ETH; var positions = new List { btcPosition, ethPosition }; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(positions); // Assert result.VolumeByAsset.Should().HaveCount(2); result.VolumeByAsset.Should().ContainKey("BTC"); result.VolumeByAsset.Should().ContainKey("ETH"); result.PositionCountByAsset.Should().HaveCount(2); result.PositionCountByAsset["BTC"].Should().Be(1); result.PositionCountByAsset["ETH"].Should().Be(1); } [Fact] public void CalculatePlatformSummaryMetrics_WithOpenPositions_IncludesOpenInterestAndDirectionCounts() { // Arrange var positions = new List { CreateFilledPosition(50000m, 0.1m, TradeDirection.Long, 2m), // Open long CreateFilledPosition(40000m, 0.2m, TradeDirection.Short, 1m), // Open short CreateFinishedPosition(30000m, 0.05m, TradeDirection.Long, 1m, 31000m, false) // Closed }; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(positions); // Assert result.TotalLifetimePositionCount.Should().Be(3); result.PositionCountByDirection.Should().HaveCount(2); result.PositionCountByDirection[TradeDirection.Long].Should().Be(1); result.PositionCountByDirection[TradeDirection.Short].Should().Be(1); result.OpenInterest.Should().BeGreaterThan(0); } [Fact] public void CalculatePlatformSummaryMetrics_WithLongPosition_CalculatesCorrectVolume() { // Arrange var position = CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 52000m, false); // Expected volume: Open volume + Close volume // Open: 50000 * 0.1 * 1 = 5000 // Close: 52000 * 0.1 * 1 = 5200 (TP1 price) var expectedVolume = 5000m + 5200m; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert result.TotalPlatformVolume.Should().Be(expectedVolume); result.VolumeByAsset["BTC"].Should().Be(expectedVolume); } [Fact] public void CalculatePlatformSummaryMetrics_WithShortPosition_CalculatesCorrectVolume() { // Arrange var position = CreateFinishedPosition(50000m, 0.1m, TradeDirection.Short, 1m, 48000m, false); // Expected volume: Open volume + Close volume // Open: 50000 * 0.1 * 1 = 5000 // Close: 48000 * 0.1 * 1 = 4800 (TP1 price) var expectedVolume = 5000m + 4800m; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert result.TotalPlatformVolume.Should().Be(expectedVolume); result.VolumeByAsset["BTC"].Should().Be(expectedVolume); } [Fact] public void CalculatePlatformSummaryMetrics_WithFlippedPosition_IncludesAllVolume() { // Arrange var position = CreateTestPosition(50000m, 0.1m, TradeDirection.Long, 1m, positionStatus: PositionStatus.Flipped, includeTrades: true); // Set up a flipped position - typically closed by TP1, then reopened in opposite direction position.Open.Status = TradeStatus.Filled; position.TakeProfit1.Status = TradeStatus.Filled; // Position was closed by take profit position.StopLoss.Status = TradeStatus.Cancelled; // SL was cancelled when TP was hit // Expected volume: Open + TP1 (since position was closed by take profit) var expectedVolume = (50000m * 0.1m * 1m) + // Open (position.TakeProfit1.Price * 0.1m * 1m); // TP1 only // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert result.TotalPlatformVolume.Should().Be(expectedVolume); result.TotalLifetimePositionCount.Should().Be(1); } [Fact] public void CalculatePlatformSummaryMetrics_WithHighLeverage_CalculatesCorrectVolume() { // Arrange var position = CreateFinishedPosition( openPrice: 50000m, quantity: 0.1m, direction: TradeDirection.Long, leverage: 50m, // Very high leverage closePrice: 51000m, closedBySL: false ); // Expected volume: Open volume + Close volume with high leverage var expectedOpenVolume = 50000m * 0.1m * 50m; // 250,000 var expectedCloseVolume = position.TakeProfit1.Price * 0.1m * 50m; // ~255,000 var expectedVolume = expectedOpenVolume + expectedCloseVolume; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert result.TotalPlatformVolume.Should().Be(expectedVolume); result.TotalPlatformVolume.Should().BeGreaterThan(500000m); // Should be over 500k with 50x leverage } [Fact] public void CalculatePlatformSummaryMetrics_WithFractionalLeverage_CalculatesCorrectVolume() { // Arrange var position = CreateFinishedPosition( openPrice: 50000m, quantity: 0.1m, direction: TradeDirection.Long, leverage: 0.5m, // Fractional leverage (less than 1) closePrice: 51000m, closedBySL: false ); // Expected volume: Open volume + Close volume with fractional leverage var expectedOpenVolume = 50000m * 0.1m * 0.5m; // 2,500 var expectedCloseVolume = position.TakeProfit1.Price * 0.1m * 0.5m; // ~2,550 var expectedVolume = expectedOpenVolume + expectedCloseVolume; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert result.TotalPlatformVolume.Should().Be(expectedVolume); result.TotalPlatformVolume.Should().BeLessThan(10000m); // Should be under 10k with 0.5x leverage } [Fact] public void CalculatePlatformSummaryMetrics_WithVerySmallQuantity_CalculatesCorrectVolume() { // Arrange var position = CreateFinishedPosition( openPrice: 50000m, quantity: 0.00001m, // Very small quantity (dust position) direction: TradeDirection.Long, leverage: 1m, closePrice: 51000m, closedBySL: false ); // Expected volume: Should handle very small numbers correctly var expectedOpenVolume = 50000m * 0.00001m * 1m; // 0.5 var expectedCloseVolume = position.TakeProfit1.Price * 0.00001m * 1m; // ~0.51 var expectedVolume = expectedOpenVolume + expectedCloseVolume; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert result.TotalPlatformVolume.Should().Be(expectedVolume); result.TotalPlatformVolume.Should().BeLessThan(2m); // Should be very small result.TotalPlatformVolume.Should().BeGreaterThan(0m); // But still positive } [Fact] public void CalculatePlatformSummaryMetrics_WithPositionClosedBySL_CalculatesCorrectVolume() { // Arrange var position = CreateFinishedPosition( openPrice: 50000m, quantity: 0.1m, direction: TradeDirection.Long, leverage: 1m, closePrice: 49000m, // Price below entry (SL hit) closedBySL: true // This creates a position closed by StopLoss ); // Expected volume: Open volume + SL volume (TP1 should be cancelled) var expectedOpenVolume = 50000m * 0.1m * 1m; // 5,000 var expectedCloseVolume = position.StopLoss.Price * 0.1m * 1m; // SL price * quantity * leverage var expectedVolume = expectedOpenVolume + expectedCloseVolume; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert result.TotalPlatformVolume.Should().Be(expectedVolume); result.TotalPlatformVolume.Should().Be(50000m * 0.1m + position.StopLoss.Price * 0.1m); // Open + SL only } [Fact] public void CalculatePlatformSummaryMetrics_WithMultipleClosingTrades_IncludesAllVolume() { // Arrange - Create a position that has both TP1 and SL filled (flipped position) var position = CreateTestPosition(50000m, 0.1m, TradeDirection.Long, 1m, positionStatus: PositionStatus.Flipped, includeTrades: true); // Manually set up a position with multiple closing trades position.Open.Status = TradeStatus.Filled; position.StopLoss.Status = TradeStatus.Filled; // SL was filled position.TakeProfit1.Status = TradeStatus.Filled; // TP1 was also filled position.TakeProfit2 = new Trade( // Add TP2 date: TestDate.AddMinutes(15), direction: TradeDirection.Short, status: TradeStatus.Filled, tradeType: TradeType.Market, ticker: Ticker.BTC, quantity: 0.05m, // Partial close price: 53000m, leverage: 1m, exchangeOrderId: Guid.NewGuid().ToString(), message: "Take Profit 2" ); // Expected volume: Open + SL + TP1 + TP2 var expectedVolume = (50000m * 0.1m * 1m) + // Open: 5,000 (position.StopLoss.Price * 0.1m * 1m) + // SL: ~4,900 (position.TakeProfit1.Price * 0.1m * 1m) + // TP1: ~5,200 (53000m * 0.05m * 1m); // TP2: 2,650 // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert result.TotalPlatformVolume.Should().Be(expectedVolume); result.TotalPlatformVolume.Should().BeGreaterThan(17000m); // Should include all trade volumes } [Fact] public void CalculatePlatformSummaryMetrics_WithMixedDirections_CalculatesSeparateDirectionCounts() { // Arrange var positions = new List { CreateFilledPosition(50000m, 0.1m, TradeDirection.Long, 1m), // Open long CreateFilledPosition(50000m, 0.1m, TradeDirection.Long, 1m), // Another open long CreateFilledPosition(40000m, 0.2m, TradeDirection.Short, 1m), // Open short CreateFinishedPosition(30000m, 0.05m, TradeDirection.Short, 1m, 29000m, false) // Closed short }; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(positions); // Assert result.TotalLifetimePositionCount.Should().Be(4); result.PositionCountByDirection.Should().HaveCount(2); result.PositionCountByDirection[TradeDirection.Long].Should().Be(2); // Two open longs result.PositionCountByDirection[TradeDirection.Short].Should().Be(1); // One open short } [Fact] public void CalculatePlatformSummaryMetrics_WithSameAssetMultiplePositions_AggregatesCorrectly() { // Arrange - Multiple BTC positions var positions = new List { CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 51000m, false), // BTC position 1 CreateFinishedPosition(52000m, 0.05m, TradeDirection.Short, 2m, 51000m, false), // BTC position 2 CreateFinishedPosition(3000m, 1m, TradeDirection.Long, 1m, 3100m, false) // ETH position }; // Set the third position to ETH positions[2].Ticker = Ticker.ETH; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(positions); // Assert result.VolumeByAsset.Should().HaveCount(2); // BTC and ETH result.PositionCountByAsset.Should().HaveCount(2); result.PositionCountByAsset["BTC"].Should().Be(2); // Two BTC positions result.PositionCountByAsset["ETH"].Should().Be(1); // One ETH position // BTC volume should be from both BTC positions using actual TP1 prices var btcVolume1 = 50000m * 0.1m * 1m + positions[0].TakeProfit1.Price * 0.1m * 1m; // Long: 50000 + 52000 var btcVolume2 = 52000m * 0.05m * 2m + positions[1].TakeProfit1.Price * 0.05m * 2m; // Short: 5200 + (49920 * 0.05 * 2) var expectedBtcVolume = btcVolume1 + btcVolume2; result.VolumeByAsset["BTC"].Should().Be(expectedBtcVolume); } [Fact] public void CalculatePlatformSummaryMetrics_WithZeroLeverage_HandlesGracefully() { // Arrange - Edge case with zero leverage (shouldn't happen in practice but test robustness) var position = CreateTestPosition(50000m, 0.1m, TradeDirection.Long, 0m, positionStatus: PositionStatus.Filled, includeTrades: true); // Act & Assert - Should not throw and should handle zero leverage var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Should still work, just with zero volume result.TotalPlatformVolume.Should().Be(0m); result.OpenInterest.Should().Be(0m); } [Fact] public void CalculatePlatformSummaryMetrics_WithExtremeLeverage_HandlesLargeNumbers() { // Arrange - Test with very high leverage to check for overflow issues var position = CreateFinishedPosition( openPrice: 50000m, quantity: 1m, direction: TradeDirection.Long, leverage: 1000m, // Extreme leverage closePrice: 50001m, // Tiny profit closedBySL: false ); // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert - Should handle large numbers without overflow result.TotalPlatformVolume.Should().BeGreaterThan(100000000m); // 100M+ with 1000x leverage result.TotalPlatformVolume.Should().Be(50000m * 1m * 1000m + position.TakeProfit1.Price * 1m * 1000m); } [Fact] public void CalculatePlatformSummaryMetrics_WithNegativePnL_CalculatesCorrectly() { // Arrange - Create a position with loss var position = CreateFinishedPosition( openPrice: 50000m, quantity: 0.1m, direction: TradeDirection.Long, leverage: 1m, closePrice: 49000m, // Lower than entry = loss closedBySL: true // Closed by SL ); // Manually set negative PnL (since positions don't calculate this automatically) position.ProfitAndLoss = new ProfitAndLoss(new List> { new Tuple(0.1m, 50000m), // Open new Tuple(-0.1m, position.StopLoss.Price) // Close at SL }, TradeDirection.Long); // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert - Should handle negative PnL correctly result.TotalPlatformPnL.Should().BeLessThan(0); // Negative PnL result.NetPnL.Should().Be(result.TotalPlatformPnL - result.TotalPlatformFees); result.NetPnL.Should().BeLessThan(0); // Net loss } [Fact] public void CalculatePlatformSummaryMetrics_WithOnlyOpenPositions_IncludesAllInOpenInterest() { // Arrange - Only open positions var positions = new List { CreateFilledPosition(50000m, 0.1m, TradeDirection.Long, 2m), // Open long CreateFilledPosition(40000m, 0.2m, TradeDirection.Short, 1m), // Open short CreateFilledPosition(60000m, 0.05m, TradeDirection.Long, 3m) // Another open long }; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(positions); // Assert result.TotalLifetimePositionCount.Should().Be(3); result.PositionCountByDirection.Should().HaveCount(2); result.PositionCountByDirection[TradeDirection.Long].Should().Be(2); result.PositionCountByDirection[TradeDirection.Short].Should().Be(1); // Open interest should include all open positions var expectedOpenInterest = (50000m * 0.1m * 2m) + (40000m * 0.2m * 1m) + (60000m * 0.05m * 3m); result.OpenInterest.Should().Be(expectedOpenInterest); } [Fact] public void CalculatePlatformSummaryMetrics_WithComplexScenario_CalculatesAllMetricsCorrectly() { // Arrange - Complex scenario with multiple positions, different statuses, assets, and directions var positions = new List { // Closed positions CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 51000m, false), // BTC Long profit CreateFinishedPosition(40000m, 0.2m, TradeDirection.Short, 2m, 41000m, true), // BTC Short loss (SL) // Open positions CreateFilledPosition(60000m, 0.05m, TradeDirection.Long, 3m), // BTC Long open CreateFilledPosition(3000m, 1m, TradeDirection.Short, 1m), // ETH Short open // Invalid positions (should be excluded) CreateCanceledPosition(50000m, 0.01m, TradeDirection.Long, 1m), // Canceled CreateRejectedPosition(40000m, 0.01m, TradeDirection.Short, 1m) // Rejected }; // Set ETH ticker for the ETH position positions[3].Ticker = Ticker.ETH; // Act var result = TradingBox.CalculatePlatformSummaryMetrics(positions); // Assert result.TotalLifetimePositionCount.Should().Be(4); // Only valid positions count // Should have both BTC and ETH result.VolumeByAsset.Should().HaveCount(2); result.PositionCountByAsset.Should().HaveCount(2); result.PositionCountByAsset["BTC"].Should().Be(3); // 2 closed + 1 open result.PositionCountByAsset["ETH"].Should().Be(1); // 1 open // Should have both directions in open positions result.PositionCountByDirection.Should().HaveCount(2); result.PositionCountByDirection[TradeDirection.Long].Should().Be(1); // 1 BTC long open result.PositionCountByDirection[TradeDirection.Short].Should().Be(1); // 1 ETH short open // Should have positive volume, some PnL, and open interest result.TotalPlatformVolume.Should().BeGreaterThan(0); result.OpenInterest.Should().BeGreaterThan(0); } [Fact] public void CalculatePlatformSummaryMetrics_WithVeryLargeNumbers_HandlesPrecisionCorrectly() { // Arrange - Test with large numbers to ensure decimal precision is maintained var position = CreateFinishedPosition( openPrice: 1000000m, // 1M price quantity: 100m, // Large quantity direction: TradeDirection.Long, leverage: 10m, // Moderate leverage closePrice: 1000100m, closedBySL: false ); // Calculate actual expected volume using the real TP1 price (not the closePrice parameter) // TP1 price = 1,000,000 * (1 + 0.04) = 1,040,000 (default takeProfitPercentage) var expectedOpenVolume = 1000000m * 100m * 10m; // 1,000,000,000 var expectedCloseVolume = position.TakeProfit1.Price * 100m * 10m; // 1,040,000 * 100 * 10 = 1,040,000,000 var expectedVolume = expectedOpenVolume + expectedCloseVolume; // 2,040,000,000 // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert result.TotalPlatformVolume.Should().Be(expectedVolume); // Ensure no precision loss occurred result.TotalPlatformVolume.Should().Be(2040000000m); } [Fact] public void CalculatePlatformSummaryMetrics_WithZeroVolumePosition_ExcludesFromVolumeCalculation() { // Arrange - Create a position with zero quantity (edge case) var position = CreateTestPosition(50000m, 0m, TradeDirection.Long, 1m, positionStatus: PositionStatus.Filled, includeTrades: true); // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert - Should handle zero quantity gracefully result.TotalPlatformVolume.Should().Be(0m); result.TotalLifetimePositionCount.Should().Be(1); // Position still counts result.OpenInterest.Should().Be(0m); } [Fact] public void CalculatePlatformSummaryMetrics_WithInvalidTicker_HandlesGracefully() { // Arrange - Position with invalid ticker (edge case) var position = CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 51000m, false); // Manually set an invalid ticker (this shouldn't happen in practice) position.Ticker = (Ticker)999; // Invalid enum value // Act var result = TradingBox.CalculatePlatformSummaryMetrics(new List { position }); // Assert - Should handle gracefully (use enum ToString) result.VolumeByAsset.Should().HaveCount(1); result.PositionCountByAsset.Should().HaveCount(1); result.TotalPlatformVolume.Should().BeGreaterThan(0); } [Fact] public void CalculateDailySnapshotFromPositions_WithDateFiltering_CalculatesCumulativeMetricsCorrectly() { // Arrange - Create positions with different dates spanning multiple days var baseDate = TestDate.Date; var positions = new List { // Day 1: Two positions (one finished, one filled) CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 51000m, false), CreateFilledPosition(40000m, 0.2m, TradeDirection.Short, 1m), // Day 2: One finished position CreateFinishedPosition(60000m, 0.05m, TradeDirection.Long, 2m, 61200m, false), // Day 3: Two positions (future dates - should be excluded) }; // Set specific dates for positions positions[0].Date = baseDate.AddHours(10); // Day 1, 10 AM positions[1].Date = baseDate.AddHours(14); // Day 1, 2 PM positions[2].Date = baseDate.AddDays(1).AddHours(11); // Day 2, 11 AM // Future positions (should be excluded) var futurePositions = new List { CreateFinishedPosition(30000m, 0.15m, TradeDirection.Short, 1m, 29100m, false), CreateFilledPosition(70000m, 0.08m, TradeDirection.Long, 1m) }; futurePositions[0].Date = baseDate.AddDays(2).AddHours(9); // Day 3, 9 AM futurePositions[1].Date = baseDate.AddDays(2).AddHours(16); // Day 3, 4 PM var allPositions = positions.Concat(futurePositions).ToList(); // Act - Calculate snapshot for Day 2 (should include Day 1 and Day 2 positions only) var targetDate = baseDate.AddDays(1); // Day 2 var filteredPositions = allPositions.Where(p => p.Date.Date <= targetDate).ToList(); var metrics = TradingBox.CalculatePlatformSummaryMetrics(filteredPositions); // Assert - Should include positions from Day 1 and Day 2 only metrics.TotalLifetimePositionCount.Should().Be(3); // 2 from Day 1 + 1 from Day 2 // Calculate expected volume: Day 1 positions + Day 2 position // Default takeProfitPercentage is 0.04m (4%) var day1Volume = (50000m * 0.1m * 1m + 52000m * 0.1m * 1m) + // Finished long: 5000 + 5200 = 10200 (40000m * 0.2m * 1m); // Open short: 8000 (not finished, no close volume) var day2Volume = (60000m * 0.05m * 2m + 62400m * 0.05m * 2m); // Finished long with 2x leverage: 6000 + 6240 = 12240 var expectedTotalVolume = day1Volume + day2Volume; // 10200 + 8000 + 12240 = 30440 metrics.TotalPlatformVolume.Should().Be(expectedTotalVolume); metrics.OpenInterest.Should().Be(40000m * 0.2m * 1m); // Only the open short position from Day 1 // Should have both BTC and position counts metrics.VolumeByAsset.Should().ContainKey("BTC"); metrics.PositionCountByAsset["BTC"].Should().Be(3); } [Fact] public void CalculateDailySnapshotFromPositions_WithNoPositionsForDate_ReturnsEmptyMetrics() { // Arrange - Create positions all after the target date var baseDate = TestDate.Date; var positions = new List { CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 51000m, false), CreateFilledPosition(40000m, 0.2m, TradeDirection.Short, 1m) }; // Set dates after target date positions[0].Date = baseDate.AddDays(1).AddHours(10); positions[1].Date = baseDate.AddDays(2).AddHours(14); // Act - Calculate snapshot for a date before all positions var targetDate = baseDate; // Today, before all positions var filteredPositions = positions.Where(p => p.Date.Date <= targetDate).ToList(); var metrics = TradingBox.CalculatePlatformSummaryMetrics(filteredPositions); // Assert - Should have no positions for this date metrics.TotalLifetimePositionCount.Should().Be(0); metrics.TotalPlatformVolume.Should().Be(0); metrics.TotalPlatformFees.Should().Be(0); metrics.TotalPlatformPnL.Should().Be(0); metrics.NetPnL.Should().Be(0); metrics.OpenInterest.Should().Be(0); metrics.VolumeByAsset.Should().BeEmpty(); metrics.PositionCountByAsset.Should().BeEmpty(); metrics.PositionCountByDirection.Should().BeEmpty(); } [Fact] public void CalculateDailySnapshotFromPositions_WithMixedAssetsAndDirections_CalculatesAssetBreakdownsCorrectly() { // Arrange - Create positions with different assets and directions over multiple days var baseDate = TestDate.Date; var positions = new List { // Day 1: BTC Long (finished) and ETH Short (open) CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 51000m, false), // BTC finished CreateFilledPosition(3000m, 1m, TradeDirection.Short, 1m), // ETH open // Day 2: BTC Short (finished) and ETH Long (finished) CreateFinishedPosition(40000m, 0.2m, TradeDirection.Short, 2m, 39200m, false), // BTC finished CreateFinishedPosition(3100m, 0.5m, TradeDirection.Long, 1m, 3180m, false) // ETH finished }; // Set dates positions[0].Date = baseDate.AddHours(10); // Day 1 BTC positions[1].Date = baseDate.AddHours(14); // Day 1 ETH positions[2].Date = baseDate.AddDays(1).AddHours(11); // Day 2 BTC positions[3].Date = baseDate.AddDays(1).AddHours(15); // Day 2 ETH // Set ETH tickers for ETH positions positions[1].Ticker = Ticker.ETH; positions[3].Ticker = Ticker.ETH; // Act - Calculate snapshot for Day 2 (includes all positions) var targetDate = baseDate.AddDays(1); var filteredPositions = positions.Where(p => p.Date.Date <= targetDate).ToList(); var metrics = TradingBox.CalculatePlatformSummaryMetrics(filteredPositions); // Assert - Should include all 4 positions metrics.TotalLifetimePositionCount.Should().Be(4); metrics.VolumeByAsset.Should().HaveCount(2); // BTC and ETH metrics.PositionCountByAsset.Should().HaveCount(2); metrics.PositionCountByAsset["BTC"].Should().Be(2); metrics.PositionCountByAsset["ETH"].Should().Be(2); // Should have only one direction (one open short ETH, rest are finished) metrics.PositionCountByDirection.Should().HaveCount(1); metrics.PositionCountByDirection[TradeDirection.Short].Should().Be(1); // Only the open ETH short position // Open interest should only include the open ETH short position metrics.OpenInterest.Should().Be(3000m * 1m * 1m); // ETH open short: 3000 } [Fact] public void CalculateDailySnapshotFromPositions_WithPositionSpanningMultipleDays_CalculatesVolumePerDayCorrectly() { // Arrange - Create positions representing the same position at different stages var baseDate = TestDate.Date; // Position as it appears on Day 1 (still open) var positionDay1 = CreateFilledPosition(50000m, 0.1m, TradeDirection.Long, 1m); positionDay1.Date = baseDate.AddHours(12); // Day 1, noon // Position as it appears on Day 2 (now closed) var positionDay2 = CreateFinishedPosition(50000m, 0.1m, TradeDirection.Long, 1m, 52000m, false); positionDay2.Date = baseDate.AddHours(12); // Same open date positionDay2.TakeProfit1.Date = baseDate.AddDays(1).AddHours(10); // Closed on Day 2 // Act - Calculate snapshot for Day 1 (position opened but not closed yet) var day1Positions = new List { positionDay1 }; var day1Metrics = TradingBox.CalculatePlatformSummaryMetrics(day1Positions); // Calculate snapshot for Day 2 (position opened and closed) var day2Positions = new List { positionDay2 }; var day2Metrics = TradingBox.CalculatePlatformSummaryMetrics(day2Positions); // Assert - Day 1: Only opening volume (position still open) var expectedDay1Volume = 50000m * 0.1m * 1m; // Open: 5000 day1Metrics.TotalPlatformVolume.Should().Be(expectedDay1Volume); day1Metrics.TotalLifetimePositionCount.Should().Be(1); day1Metrics.OpenInterest.Should().Be(expectedDay1Volume); // Position still open // Assert - Day 2: Opening volume + closing volume (position now closed) var expectedDay2Volume = 50000m * 0.1m * 1m + 52000m * 0.1m * 1m; // Open: 5000 + Close: 5200 = 10200 day2Metrics.TotalPlatformVolume.Should().Be(expectedDay2Volume); day2Metrics.TotalLifetimePositionCount.Should().Be(1); day2Metrics.OpenInterest.Should().Be(0m); // Position now closed // Assert - Volume increased from Day 1 to Day 2 (closing volume added) day2Metrics.TotalPlatformVolume.Should().BeGreaterThan(day1Metrics.TotalPlatformVolume); var volumeIncrease = day2Metrics.TotalPlatformVolume - day1Metrics.TotalPlatformVolume; volumeIncrease.Should().Be(5200m); // The closing volume added on Day 2 } }