diff --git a/src/Managing.Application/Grains/PlatformSummaryGrain.cs b/src/Managing.Application/Grains/PlatformSummaryGrain.cs index 0f1e3657..9da59989 100644 --- a/src/Managing.Application/Grains/PlatformSummaryGrain.cs +++ b/src/Managing.Application/Grains/PlatformSummaryGrain.cs @@ -3,6 +3,7 @@ using Managing.Application.Abstractions.Grains; using Managing.Application.Abstractions.Services; using Managing.Application.Orleans; using Managing.Domain.Candles; +using Managing.Domain.Shared.Helpers; using Managing.Domain.Trades; using Microsoft.Extensions.Logging; using static Managing.Common.Enums; @@ -140,172 +141,39 @@ public class PlatformSummaryGrain : Grain, IPlatformSummaryGrain, IRemindable { _logger.LogInformation("Refreshing platform summary data"); - var positions = await _tradingService.GetAllDatabasePositionsAsync(); + var positions = (await _tradingService.GetAllDatabasePositionsAsync()).ToList(); - // Get the last daily snapshot date to calculate cumulative volume - var lastSnapshotDate = _state.State.DailySnapshots.Any() - ? _state.State.DailySnapshots.Max(s => s.Date) - : DateTime.MinValue; + // Use TradingBox to calculate all platform metrics + var metrics = TradingBox.CalculatePlatformSummaryMetrics(positions, _state.State.TotalPlatformVolume); - // Start with the cumulative volume from the last snapshot - var cumulativeVolume = _state.State.DailySnapshots.Any() - ? _state.State.DailySnapshots.OrderByDescending(s => s.Date).First().TotalVolume - : 0m; + // Update state with calculated metrics + _state.State.TotalPlatformVolume = metrics.TotalPlatformVolume; + _state.State.TotalPlatformFees = metrics.TotalPlatformFees; + _state.State.TotalPlatformPnL = metrics.TotalPlatformPnL; + _state.State.NetPnL = metrics.NetPnL; + _state.State.OpenInterest = metrics.OpenInterest; + _state.State.TotalLifetimePositionCount = metrics.TotalLifetimePositionCount; - _logger.LogInformation( - "Calculating cumulative volume from last snapshot date: {LastSnapshotDate}, Base volume: {CumulativeVolume}", - lastSnapshotDate, cumulativeVolume); + // Convert string keys to Ticker enum keys + _state.State.VolumeByAsset = metrics.VolumeByAsset.ToDictionary( + kvp => Enum.Parse(kvp.Key), + kvp => kvp.Value); + _state.State.PositionCountByAsset = metrics.PositionCountByAsset.ToDictionary( + kvp => Enum.Parse(kvp.Key), + kvp => kvp.Value); + _state.State.PositionCountByDirection = metrics.PositionCountByDirection; - // Calculate all metrics from positions in a single loop - var newVolume = 0m; // Volume from positions after last snapshot - var totalVolumeFromAllPositions = 0m; // Total volume calculated from ALL positions (for comparison) - var totalFees = 0m; - var totalPnL = 0m; - var totalOpenInterest = 0m; - var totalPositionCount = 0; - - // Clear state dictionaries at the start - _state.State.VolumeByAsset.Clear(); - _state.State.PositionCountByAsset.Clear(); - _state.State.PositionCountByDirection.Clear(); - - foreach (var position in positions) - { - if (!position.IsValidForMetrics()) continue; - - // Calculate volume using the same logic as daily snapshots for consistency - // Opening volume is always counted (for positions opened on or before today) - var openVolume = position.Open.Price * position.Open.Quantity * position.Open.Leverage; - var closingVolume = 0m; - - // Only include closing volume from trades that are filled - if (position.Status == PositionStatus.Finished || position.Status == PositionStatus.Flipped) - { - if (position.StopLoss?.Status == TradeStatus.Filled) - { - closingVolume += position.StopLoss.Price * position.StopLoss.Quantity * position.StopLoss.Leverage; - } - - if (position.TakeProfit1?.Status == TradeStatus.Filled) - { - closingVolume += position.TakeProfit1.Price * position.TakeProfit1.Quantity * position.TakeProfit1.Leverage; - } - - if (position.TakeProfit2?.Status == TradeStatus.Filled) - { - closingVolume += position.TakeProfit2.Price * position.TakeProfit2.Quantity * position.TakeProfit2.Leverage; - } - } - - var positionVolume = openVolume + closingVolume; - - // Track total volume from ALL positions (this is the true cumulative volume) - totalVolumeFromAllPositions += positionVolume; - - // For cumulative volume: only add volume from positions created AFTER last snapshot - // For volume breakdown: include all positions - if (position.Date.Date > lastSnapshotDate) - { - newVolume += positionVolume; - _logger.LogInformation( - "Position {PositionId} created after last snapshot ({PositionDate} > {LastSnapshotDate}), adding volume: {Volume}", - position.Identifier, position.Date.Date, lastSnapshotDate, positionVolume); - } - else - { - _logger.LogDebug( - "Position {PositionId} created before/on last snapshot ({PositionDate} <= {LastSnapshotDate}), skipping volume: {Volume}", - position.Identifier, position.Date.Date, lastSnapshotDate, positionVolume); - } - - // Calculate breakdown metrics from ALL positions (for current state) - var ticker = position.Ticker; - var direction = position.OriginDirection; - - // Volume breakdown by asset - update state directly - if (!_state.State.VolumeByAsset.ContainsKey(ticker)) - { - _state.State.VolumeByAsset[ticker] = 0; - } - - _state.State.VolumeByAsset[ticker] += positionVolume; - - // Position count breakdown by asset - update state directly - if (!_state.State.PositionCountByAsset.ContainsKey(ticker)) - { - _state.State.PositionCountByAsset[ticker] = 0; - } - - _state.State.PositionCountByAsset[ticker]++; - - // Calculate fees and PnL for all positions - totalFees += position.CalculateTotalFees(); - totalPnL += position.ProfitAndLoss?.Realized ?? 0; - - // Count all positions - totalPositionCount++; - - // Position count breakdown by direction - only count open positions - if (position.IsOpen()) - { - var openingVolume = position.Open.Price * position.Open.Quantity * position.Open.Leverage; - totalOpenInterest += openingVolume; - - if (!_state.State.PositionCountByDirection.ContainsKey(direction)) - { - _state.State.PositionCountByDirection[direction] = 0; - } - - _state.State.PositionCountByDirection[direction]++; - } - } - - // CUMULATIVE volume: baseline + new volume since last snapshot - var updatedCumulativeVolume = cumulativeVolume + newVolume; - - _logger.LogWarning( - "Volume calculation DEBUG: TotalFromAllPositions={TotalFromAll}, LastSnapshotVolume={LastSnapshot}, NewVolumeSinceSnapshot={NewVolume}, CalculatedCumulative={Cumulative}, CurrentState={CurrentState}", - totalVolumeFromAllPositions, cumulativeVolume, newVolume, updatedCumulativeVolume, - _state.State.TotalPlatformVolume); - - // ISSUE FIX: The correct cumulative volume should be calculated from ALL positions - // not just by adding new positions to the last snapshot, because: - // 1. Positions might be closed/updated which affects their volume - // 2. The daily snapshot might be outdated - // We should use the total from all positions, but never let it decrease - var correctCumulativeVolume = totalVolumeFromAllPositions; - - // Ensure volume never decreases - if (correctCumulativeVolume < _state.State.TotalPlatformVolume) - { - _logger.LogWarning( - "Calculated volume ({Calculated}) is less than current volume ({Current}). Keeping current value to maintain cumulative nature.", - correctCumulativeVolume, _state.State.TotalPlatformVolume); - correctCumulativeVolume = _state.State.TotalPlatformVolume; - } - - _state.State.TotalPlatformVolume = correctCumulativeVolume; - - _logger.LogInformation("Final volume set to: {FinalVolume}", correctCumulativeVolume); - _state.State.TotalPlatformFees = totalFees; - _state.State.TotalPlatformPnL = totalPnL; - _state.State.NetPnL = totalPnL - totalFees; // Calculate NetPnL - _state.State.OpenInterest = totalOpenInterest; - _state.State.TotalLifetimePositionCount = totalPositionCount; _state.State.HasPendingChanges = false; - _logger.LogDebug( - "Updated position breakdown from positions: {AssetCount} assets, Long={LongPositions}, Short={ShortPositions}", - _state.State.PositionCountByAsset.Count, - _state.State.PositionCountByDirection.GetValueOrDefault(TradeDirection.Long, 0), - _state.State.PositionCountByDirection.GetValueOrDefault(TradeDirection.Short, 0)); + _logger.LogInformation("Platform summary data refreshed - Volume: {Volume}, PnL: {PnL}, Positions: {Positions}", + metrics.TotalPlatformVolume, metrics.TotalPlatformPnL, metrics.TotalLifetimePositionCount); _state.State.LastUpdated = DateTime.UtcNow; await RefreshAgentCountAsync(); await _state.WriteStateAsync(); _logger.LogInformation("Platform summary data refreshed successfully - Cumulative volume: {Volume}", - updatedCumulativeVolume); + metrics.TotalPlatformVolume); } catch (Exception ex) { @@ -642,145 +510,34 @@ public class PlatformSummaryGrain : Grain, IPlatformSummaryGrain, IRemindable private async Task CalculateDailySnapshotFromPositionsAsync(List positions, DateTime targetDate) { - // Calculate CUMULATIVE metrics: sum of ALL volume/PnL from positions with activity on or before target date - var totalVolume = 0m; - var totalFees = 0m; - var totalPnL = 0m; - var currentOpenInterest = 0m; - var totalPositionCount = 0; - _logger.LogInformation("Calculating snapshot for {TargetDate} with {PositionCount} positions", targetDate, positions.Count); - foreach (var position in positions) - { - // Only include positions that were OPENED on or before the target date - if (position.Date.Date > targetDate) - { - _logger.LogDebug("Position {PositionId} opened after target date ({PositionDate} > {TargetDate}), skipping", - position.Identifier, position.Date.Date, targetDate); - continue; - } + // Filter positions to only include those opened on or before the target date + var filteredPositions = positions.Where(p => p.Date.Date <= targetDate).ToList(); - // Count this position in the cumulative count - totalPositionCount++; - - // Calculate volume for this position using SQL logic: - // OpenVolume = Price * Quantity * Leverage (from opening trade) - var openVolume = position.Open.Price * position.Open.Quantity * position.Open.Leverage; - var closingVolume = 0m; - - // ClosingVolume = Sum of all filled closing trades (SL, TP1, TP2) that happened on or before target date - // IMPORTANT: Only count closing volume from trades that were filled on or BEFORE the target date - if (position.Status == PositionStatus.Finished || position.Status == PositionStatus.Flipped) - { - // Stop Loss volume (if filled and on or before target date) - if (position.StopLoss?.Status == TradeStatus.Filled && position.StopLoss.Date.Date <= targetDate) - { - closingVolume += position.StopLoss.Price * position.StopLoss.Quantity * position.StopLoss.Leverage; - _logger.LogDebug("Position {PositionId}: Including SL closing volume {Volume} (filled on {Date})", - position.Identifier, position.StopLoss.Price * position.StopLoss.Quantity * position.StopLoss.Leverage, position.StopLoss.Date.Date); - } - else if (position.StopLoss?.Status == TradeStatus.Filled) - { - _logger.LogDebug("Position {PositionId}: Excluding SL closing volume (filled on {Date} > target {TargetDate})", - position.Identifier, position.StopLoss.Date.Date, targetDate); - } - - // Take Profit 1 volume (if filled and on or before target date) - if (position.TakeProfit1?.Status == TradeStatus.Filled && position.TakeProfit1.Date.Date <= targetDate) - { - closingVolume += position.TakeProfit1.Price * position.TakeProfit1.Quantity * position.TakeProfit1.Leverage; - _logger.LogDebug("Position {PositionId}: Including TP1 closing volume {Volume} (filled on {Date})", - position.Identifier, position.TakeProfit1.Price * position.TakeProfit1.Quantity * position.TakeProfit1.Leverage, position.TakeProfit1.Date.Date); - } - else if (position.TakeProfit1?.Status == TradeStatus.Filled) - { - _logger.LogDebug("Position {PositionId}: Excluding TP1 closing volume (filled on {Date} > target {TargetDate})", - position.Identifier, position.TakeProfit1.Date.Date, targetDate); - } - - // Take Profit 2 volume (if filled and on or before target date) - if (position.TakeProfit2?.Status == TradeStatus.Filled && position.TakeProfit2.Date.Date <= targetDate) - { - closingVolume += position.TakeProfit2.Price * position.TakeProfit2.Quantity * position.TakeProfit2.Leverage; - _logger.LogDebug("Position {PositionId}: Including TP2 closing volume {Volume} (filled on {Date})", - position.Identifier, position.TakeProfit2.Price * position.TakeProfit2.Quantity * position.TakeProfit2.Leverage, position.TakeProfit2.Date.Date); - } - else if (position.TakeProfit2?.Status == TradeStatus.Filled) - { - _logger.LogDebug("Position {PositionId}: Excluding TP2 closing volume (filled on {Date} > target {TargetDate})", - position.Identifier, position.TakeProfit2.Date.Date, targetDate); - } - } - // For positions that are still open, no closing volume yet - else if (position.IsOpen()) - { - _logger.LogDebug("Position {PositionId}: Still open, no closing volume", position.Identifier); - } - - // Total volume for this position = opening + closing (only what happened by target date) - var positionVolume = openVolume + closingVolume; - totalVolume += positionVolume; - - _logger.LogDebug( - "Position {PositionId} (opened {OpenDate}): OpenVolume={OpenVol}, ClosingVolume={CloseVol}, Total={Total}", - position.Identifier, position.Date.Date, openVolume, closingVolume, positionVolume); - - // Calculate open interest (only for positions that are still open on target date) - if (position.IsOpen()) - { - currentOpenInterest += openVolume; - } - else if (position.Status == PositionStatus.Finished || position.Status == PositionStatus.Flipped) - { - // Check if position was still open on the target date (closed after target date) - var closedAfterTargetDate = - (position.StopLoss?.Status == TradeStatus.Filled && position.StopLoss.Date.Date > targetDate) || - (position.TakeProfit1?.Status == TradeStatus.Filled && position.TakeProfit1.Date.Date > targetDate) || - (position.TakeProfit2?.Status == TradeStatus.Filled && position.TakeProfit2.Date.Date > targetDate); - - if (closedAfterTargetDate) - { - currentOpenInterest += openVolume; - } - } - - // Calculate fees and PnL for FINISHED positions (only if closed on or before target date) - if (position.Status == PositionStatus.Finished || position.Status == PositionStatus.Flipped) - { - var wasClosedByTargetDate = - (position.StopLoss?.Status == TradeStatus.Filled && position.StopLoss.Date.Date <= targetDate) || - (position.TakeProfit1?.Status == TradeStatus.Filled && position.TakeProfit1.Date.Date <= targetDate) || - (position.TakeProfit2?.Status == TradeStatus.Filled && position.TakeProfit2.Date.Date <= targetDate); - - if (wasClosedByTargetDate) - { - totalFees += position.CalculateTotalFees(); - totalPnL += position.ProfitAndLoss?.Realized ?? 0; - } - } - } + // Use TradingBox to calculate metrics for filtered positions + var metrics = TradingBox.CalculatePlatformSummaryMetrics(filteredPositions); // Get current agent and strategy counts (these are current state, not historical) var totalAgents = await _agentService.GetTotalAgentCount(); var totalStrategies = _state.State.TotalActiveStrategies; _logger.LogInformation( - "Calculated CUMULATIVE snapshot for {TargetDate}: CumVolume={TotalVolume}, OpenInterest={OpenInterest}, CumPositionCount={TotalPositionCount}, Fees={Fees}, PnL={PnL}", - targetDate, totalVolume, currentOpenInterest, totalPositionCount, totalFees, totalPnL); + "Calculated CUMULATIVE snapshot for {TargetDate}: Volume={TotalVolume}, OpenInterest={OpenInterest}, PositionCount={TotalPositionCount}, Fees={Fees}, PnL={PnL}", + targetDate, metrics.TotalPlatformVolume, metrics.OpenInterest, metrics.TotalLifetimePositionCount, metrics.TotalPlatformFees, metrics.TotalPlatformPnL); return new DailySnapshot { Date = targetDate, TotalAgents = totalAgents, TotalStrategies = totalStrategies, - TotalVolume = totalVolume, - TotalPnL = totalPnL, - NetPnL = totalPnL - totalFees, - TotalOpenInterest = currentOpenInterest, - TotalLifetimePositionCount = totalPositionCount, - TotalPlatformFees = (int)totalFees, + TotalVolume = metrics.TotalPlatformVolume, + TotalPnL = metrics.TotalPlatformPnL, + NetPnL = metrics.NetPnL, + TotalOpenInterest = metrics.OpenInterest, + TotalLifetimePositionCount = metrics.TotalLifetimePositionCount, + TotalPlatformFees = (int)metrics.TotalPlatformFees, }; } } \ No newline at end of file diff --git a/src/Managing.Domain.Tests/PlatformSummaryMetricsTests.cs b/src/Managing.Domain.Tests/PlatformSummaryMetricsTests.cs new file mode 100644 index 00000000..c1bd62ca --- /dev/null +++ b/src/Managing.Domain.Tests/PlatformSummaryMetricsTests.cs @@ -0,0 +1,803 @@ +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); + } +} diff --git a/src/Managing.Domain.Tests/TradingBoxTests.cs b/src/Managing.Domain.Tests/TradingBoxTests.cs index 170a346c..7d2ffb4f 100644 --- a/src/Managing.Domain.Tests/TradingBoxTests.cs +++ b/src/Managing.Domain.Tests/TradingBoxTests.cs @@ -1,9 +1,6 @@ -using FluentAssertions; -using Managing.Common; -using Managing.Domain.Candles; -using Managing.Domain.Shared.Helpers; +using Managing.Domain.Candles; using Managing.Domain.Trades; -using Xunit; +using Managing.Domain.Users; using static Managing.Common.Enums; namespace Managing.Domain.Tests; @@ -44,7 +41,7 @@ public class TradingBoxTests PositionStatus positionStatus = PositionStatus.Filled, PositionInitiator initiator = PositionInitiator.User, bool includeTrades = true) { - var user = new Managing.Domain.Users.User { Id = 1, Name = "TestUser" }; + var user = new User { Id = 1, Name = "TestUser" }; var moneyManagement = new LightMoneyManagement { Name = "TestMM", @@ -245,4 +242,5 @@ public class TradingBoxTests return CreateTestPosition(openPrice, quantity, direction, leverage, positionStatus: positionStatus, initiator: PositionInitiator.Bot, includeTrades: true); } + } \ No newline at end of file diff --git a/src/Managing.Domain/Shared/Helpers/TradingBox.cs b/src/Managing.Domain/Shared/Helpers/TradingBox.cs index e5e152aa..1b933244 100644 --- a/src/Managing.Domain/Shared/Helpers/TradingBox.cs +++ b/src/Managing.Domain/Shared/Helpers/TradingBox.cs @@ -571,6 +571,17 @@ public static class TradingBox decimal TotalFees, decimal Collateral); + public record PlatformSummaryMetrics( + decimal TotalPlatformVolume, + decimal TotalPlatformFees, + decimal TotalPlatformPnL, + decimal NetPnL, + decimal OpenInterest, + int TotalLifetimePositionCount, + Dictionary VolumeByAsset, + Dictionary PositionCountByAsset, + Dictionary PositionCountByDirection); + public static AgentSummaryMetrics CalculateAgentSummaryMetrics(List positions) { var validPositions = positions? @@ -595,6 +606,141 @@ public static class TradingBox collateral); } + /// + /// Calculates comprehensive platform summary metrics from a list of positions. + /// This includes volume, PnL, fees, open interest, and breakdowns by asset/direction. + /// + /// List of all positions to analyze + /// Previous total volume to ensure cumulative volume never decreases + /// PlatformSummaryMetrics with all calculated values + public static PlatformSummaryMetrics CalculatePlatformSummaryMetrics(List positions, decimal previousTotalVolume = 0m) + { + if (positions == null || !positions.Any()) + { + return new PlatformSummaryMetrics( + TotalPlatformVolume: 0m, + TotalPlatformFees: 0m, + TotalPlatformPnL: 0m, + NetPnL: 0m, + OpenInterest: 0m, + TotalLifetimePositionCount: 0, + VolumeByAsset: new Dictionary(), + PositionCountByAsset: new Dictionary(), + PositionCountByDirection: new Dictionary() + ); + } + + // Initialize result variables + var volumeByAsset = new Dictionary(); + var positionCountByAsset = new Dictionary(); + var positionCountByDirection = new Dictionary(); + + decimal totalVolumeFromAllPositions = 0m; + decimal totalFees = 0m; + decimal totalPnL = 0m; + decimal totalOpenInterest = 0m; + int totalPositionCount = 0; + + foreach (var position in positions) + { + if (!position.IsValidForMetrics()) continue; + + // Calculate volume using the same logic as daily snapshots for consistency + // Opening volume is always counted (for positions opened on or before today) + var openVolume = position.Open.Price * position.Open.Quantity * position.Open.Leverage; + var closingVolume = 0m; + + // Only include closing volume from trades that are filled + if (position.Status == PositionStatus.Finished || position.Status == PositionStatus.Flipped) + { + if (position.StopLoss?.Status == TradeStatus.Filled) + { + closingVolume += position.StopLoss.Price * position.StopLoss.Quantity * position.StopLoss.Leverage; + } + + if (position.TakeProfit1?.Status == TradeStatus.Filled) + { + closingVolume += position.TakeProfit1.Price * position.TakeProfit1.Quantity * position.TakeProfit1.Leverage; + } + + if (position.TakeProfit2?.Status == TradeStatus.Filled) + { + closingVolume += position.TakeProfit2.Price * position.TakeProfit2.Quantity * position.TakeProfit2.Leverage; + } + } + + var positionVolume = openVolume + closingVolume; + + // Track total volume from ALL positions (this is the true cumulative volume) + totalVolumeFromAllPositions += positionVolume; + + // Calculate breakdown metrics from ALL positions (for current state) + var ticker = position.Ticker.ToString(); + var direction = position.OriginDirection; + + // Volume breakdown by asset - update state directly + if (!volumeByAsset.ContainsKey(ticker)) + { + volumeByAsset[ticker] = 0; + } + volumeByAsset[ticker] += positionVolume; + + // Position count breakdown by asset - update state directly + if (!positionCountByAsset.ContainsKey(ticker)) + { + positionCountByAsset[ticker] = 0; + } + positionCountByAsset[ticker]++; + + // Calculate fees and PnL for all positions + totalFees += position.CalculateTotalFees(); + totalPnL += position.ProfitAndLoss?.Realized ?? 0; + + // Count all positions + totalPositionCount++; + + // Position count breakdown by direction - only count open positions + if (position.IsOpen()) + { + var openingVolume = position.Open.Price * position.Open.Quantity * position.Open.Leverage; + totalOpenInterest += openingVolume; + + if (!positionCountByDirection.ContainsKey(direction)) + { + positionCountByDirection[direction] = 0; + } + positionCountByDirection[direction]++; + } + } + + // ISSUE FIX: The correct cumulative volume should be calculated from ALL positions + // not just by adding new positions to the last snapshot, because: + // 1. Positions might be closed/updated which affects their volume + // 2. The daily snapshot might be outdated + // We should use the total from all positions, but never let it decrease + var correctCumulativeVolume = totalVolumeFromAllPositions; + + // Ensure volume never decreases + if (correctCumulativeVolume < previousTotalVolume) + { + correctCumulativeVolume = previousTotalVolume; + } + + var netPnL = totalPnL - totalFees; + + return new PlatformSummaryMetrics( + TotalPlatformVolume: correctCumulativeVolume, + TotalPlatformFees: totalFees, + TotalPlatformPnL: totalPnL, + NetPnL: netPnL, + OpenInterest: totalOpenInterest, + TotalLifetimePositionCount: totalPositionCount, + VolumeByAsset: volumeByAsset, + PositionCountByAsset: positionCountByAsset, + PositionCountByDirection: positionCountByDirection + ); + } + /// /// Calculates the volume traded in the last 24 hours ///