Add test for platform summary calculation
This commit is contained in:
@@ -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<Ticker>(kvp.Key),
|
||||
kvp => kvp.Value);
|
||||
_state.State.PositionCountByAsset = metrics.PositionCountByAsset.ToDictionary(
|
||||
kvp => Enum.Parse<Ticker>(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<DailySnapshot> CalculateDailySnapshotFromPositionsAsync(List<Position> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
803
src/Managing.Domain.Tests/PlatformSummaryMetricsTests.cs
Normal file
803
src/Managing.Domain.Tests/PlatformSummaryMetricsTests.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Platform Summary Metrics calculations in TradingBox
|
||||
/// </summary>
|
||||
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<Position>();
|
||||
|
||||
// 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> { 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<Position>
|
||||
{
|
||||
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> { position }, previousVolume);
|
||||
|
||||
// Assert
|
||||
result.TotalPlatformVolume.Should().Be(previousVolume); // Should not decrease
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePlatformSummaryMetrics_WithInvalidPositions_ExcludesThemFromCalculations()
|
||||
{
|
||||
// Arrange
|
||||
var positions = new List<Position>
|
||||
{
|
||||
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<Position> { 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<Position>
|
||||
{
|
||||
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> { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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<Position>
|
||||
{
|
||||
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<Position>
|
||||
{
|
||||
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> { 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> { 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<Tuple<decimal, decimal>>
|
||||
{
|
||||
new Tuple<decimal, decimal>(0.1m, 50000m), // Open
|
||||
new Tuple<decimal, decimal>(-0.1m, position.StopLoss.Price) // Close at SL
|
||||
}, TradeDirection.Long);
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculatePlatformSummaryMetrics(new List<Position> { 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<Position>
|
||||
{
|
||||
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<Position>
|
||||
{
|
||||
// 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> { 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> { 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> { position });
|
||||
|
||||
// Assert - Should handle gracefully (use enum ToString)
|
||||
result.VolumeByAsset.Should().HaveCount(1);
|
||||
result.PositionCountByAsset.Should().HaveCount(1);
|
||||
result.TotalPlatformVolume.Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<string, decimal> VolumeByAsset,
|
||||
Dictionary<string, int> PositionCountByAsset,
|
||||
Dictionary<TradeDirection, int> PositionCountByDirection);
|
||||
|
||||
public static AgentSummaryMetrics CalculateAgentSummaryMetrics(List<Position> positions)
|
||||
{
|
||||
var validPositions = positions?
|
||||
@@ -595,6 +606,141 @@ public static class TradingBox
|
||||
collateral);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates comprehensive platform summary metrics from a list of positions.
|
||||
/// This includes volume, PnL, fees, open interest, and breakdowns by asset/direction.
|
||||
/// </summary>
|
||||
/// <param name="positions">List of all positions to analyze</param>
|
||||
/// <param name="previousTotalVolume">Previous total volume to ensure cumulative volume never decreases</param>
|
||||
/// <returns>PlatformSummaryMetrics with all calculated values</returns>
|
||||
public static PlatformSummaryMetrics CalculatePlatformSummaryMetrics(List<Position> 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<string, decimal>(),
|
||||
PositionCountByAsset: new Dictionary<string, int>(),
|
||||
PositionCountByDirection: new Dictionary<TradeDirection, int>()
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize result variables
|
||||
var volumeByAsset = new Dictionary<string, decimal>();
|
||||
var positionCountByAsset = new Dictionary<string, int>();
|
||||
var positionCountByDirection = new Dictionary<TradeDirection, int>();
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the volume traded in the last 24 hours
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user