560 lines
18 KiB
C#
560 lines
18 KiB
C#
using FluentAssertions;
|
|
using Managing.Common;
|
|
using Managing.Domain.Accounts;
|
|
using Managing.Domain.Candles;
|
|
using Managing.Domain.Indicators;
|
|
using Managing.Domain.MoneyManagements;
|
|
using Managing.Domain.Scenarios;
|
|
using Managing.Domain.Shared.Helpers;
|
|
using Managing.Domain.Statistics;
|
|
using Managing.Domain.Strategies;
|
|
using Managing.Domain.Strategies.Base;
|
|
using Managing.Domain.Trades;
|
|
using Xunit;
|
|
using static Managing.Common.Enums;
|
|
|
|
/// <summary>
|
|
/// Tests for trading metrics calculation methods in TradingBox.
|
|
/// Covers volume, P&L, win rate, and fee calculations.
|
|
/// </summary>
|
|
public class TradingMetricsTests
|
|
{
|
|
protected static readonly DateTime TestDate = new(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
|
|
|
// Test data builders
|
|
protected static Candle CreateTestCandle(decimal open = 100m, decimal high = 110m, decimal low = 90m, decimal close = 105m,
|
|
DateTime? date = null, Ticker ticker = Ticker.BTC, Timeframe timeframe = Timeframe.OneHour)
|
|
{
|
|
return new Candle
|
|
{
|
|
Open = open,
|
|
High = high,
|
|
Low = low,
|
|
Close = close,
|
|
Date = date ?? TestDate,
|
|
Ticker = ticker,
|
|
Timeframe = timeframe,
|
|
Exchange = TradingExchanges.Binance,
|
|
Volume = 1000
|
|
};
|
|
}
|
|
|
|
// Enhanced position builder for trading metrics tests
|
|
protected static Position CreateTestPosition(decimal openPrice = 50000m, decimal quantity = 0.001m,
|
|
TradeDirection direction = TradeDirection.Long, decimal leverage = 1m,
|
|
decimal? stopLossPercentage = 0.02m, decimal? takeProfitPercentage = 0.04m,
|
|
bool isClosed = false, decimal? closePrice = null, DateTime? closeDate = null)
|
|
{
|
|
var user = new Managing.Domain.Users.User { Id = 1, Name = "TestUser" };
|
|
var moneyManagement = new LightMoneyManagement
|
|
{
|
|
Name = "TestMM",
|
|
Timeframe = Timeframe.OneHour,
|
|
StopLoss = stopLossPercentage ?? 0.02m,
|
|
TakeProfit = takeProfitPercentage ?? 0.04m,
|
|
Leverage = leverage
|
|
};
|
|
|
|
var position = new Position(
|
|
identifier: Guid.NewGuid(),
|
|
accountId: 1,
|
|
originDirection: direction,
|
|
ticker: Ticker.BTC,
|
|
moneyManagement: moneyManagement,
|
|
initiator: PositionInitiator.User,
|
|
date: TestDate,
|
|
user: user
|
|
);
|
|
|
|
// Set the Open trade
|
|
position.Open = new Trade(
|
|
date: TestDate,
|
|
direction: direction,
|
|
status: TradeStatus.Filled,
|
|
tradeType: TradeType.Market,
|
|
ticker: Ticker.BTC,
|
|
quantity: quantity,
|
|
price: openPrice,
|
|
leverage: leverage,
|
|
exchangeOrderId: Guid.NewGuid().ToString(),
|
|
message: "Open position"
|
|
);
|
|
|
|
// Calculate SL/TP prices based on direction
|
|
decimal stopLossPrice, takeProfitPrice;
|
|
|
|
if (direction == TradeDirection.Long)
|
|
{
|
|
stopLossPrice = openPrice * (1 - (stopLossPercentage ?? 0.02m));
|
|
takeProfitPrice = openPrice * (1 + (takeProfitPercentage ?? 0.04m));
|
|
}
|
|
else // Short
|
|
{
|
|
stopLossPrice = openPrice * (1 + (stopLossPercentage ?? 0.02m));
|
|
takeProfitPrice = openPrice * (1 - (takeProfitPercentage ?? 0.04m));
|
|
}
|
|
|
|
// Set the StopLoss trade
|
|
position.StopLoss = new Trade(
|
|
date: TestDate.AddMinutes(5),
|
|
direction: direction == TradeDirection.Long ? TradeDirection.Short : TradeDirection.Long,
|
|
status: isClosed && closePrice.HasValue ? TradeStatus.Filled : TradeStatus.PendingOpen,
|
|
tradeType: TradeType.Market,
|
|
ticker: Ticker.BTC,
|
|
quantity: quantity,
|
|
price: isClosed && closePrice.HasValue ? closePrice.Value : stopLossPrice,
|
|
leverage: leverage,
|
|
exchangeOrderId: Guid.NewGuid().ToString(),
|
|
message: "Stop Loss"
|
|
);
|
|
|
|
// Set the TakeProfit trade
|
|
position.TakeProfit1 = new Trade(
|
|
date: closeDate ?? TestDate.AddMinutes(10),
|
|
direction: direction == TradeDirection.Long ? TradeDirection.Short : TradeDirection.Long,
|
|
status: isClosed && closePrice.HasValue ? TradeStatus.Filled : TradeStatus.PendingOpen,
|
|
tradeType: TradeType.Market,
|
|
ticker: Ticker.BTC,
|
|
quantity: quantity,
|
|
price: isClosed && closePrice.HasValue ? closePrice.Value : takeProfitPrice,
|
|
leverage: leverage,
|
|
exchangeOrderId: Guid.NewGuid().ToString(),
|
|
message: "Take Profit"
|
|
);
|
|
|
|
return position;
|
|
}
|
|
|
|
[Fact]
|
|
public void GetTotalVolumeTraded_WithEmptyPositions_ReturnsZero()
|
|
{
|
|
// Arrange
|
|
var positions = new List<Position>();
|
|
|
|
// Act
|
|
var result = TradingBox.GetTotalVolumeTraded(positions);
|
|
|
|
// Assert
|
|
result.Should().Be(0m);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetTotalVolumeTraded_WithSinglePosition_CalculatesCorrectVolume()
|
|
{
|
|
// Arrange
|
|
var position = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 2m);
|
|
var positions = new List<Position> { position };
|
|
|
|
// Act
|
|
var result = TradingBox.GetTotalVolumeTraded(positions);
|
|
|
|
// Assert - Volume = (price * quantity * leverage) for both open and close
|
|
var expectedVolume = (50000m * 0.001m * 2m) * 2; // Open and close volume
|
|
result.Should().Be(expectedVolume);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetTotalVolumeTraded_WithMultiplePositions_SumsAllVolumes()
|
|
{
|
|
// Arrange
|
|
var position1 = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m);
|
|
var position2 = CreateTestPosition(openPrice: 60000m, quantity: 0.002m, leverage: 2m);
|
|
var positions = new List<Position> { position1, position2 };
|
|
|
|
// Act
|
|
var result = TradingBox.GetTotalVolumeTraded(positions);
|
|
|
|
// Assert
|
|
var expectedVolume1 = (50000m * 0.001m * 1m) * 2; // Position 1
|
|
var expectedVolume2 = (60000m * 0.002m * 2m) * 2; // Position 2
|
|
var expectedTotal = expectedVolume1 + expectedVolume2;
|
|
result.Should().Be(expectedTotal);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetLast24HVolumeTraded_WithEmptyPositions_ReturnsZero()
|
|
{
|
|
// Arrange
|
|
var positions = new Dictionary<Guid, Position>();
|
|
|
|
// Act
|
|
var result = TradingBox.GetLast24HVolumeTraded(positions);
|
|
|
|
// Assert
|
|
result.Should().Be(0m);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetLast24HVolumeTraded_WithRecentTrades_IncludesRecentVolume()
|
|
{
|
|
// Arrange
|
|
var recentPosition = CreateTestPosition();
|
|
recentPosition.Open.Date = DateTime.UtcNow.AddHours(-12); // Within 24 hours
|
|
var positions = new Dictionary<Guid, Position> { { recentPosition.Identifier, recentPosition } };
|
|
|
|
// Act
|
|
var result = TradingBox.GetLast24HVolumeTraded(positions);
|
|
|
|
// Assert
|
|
var expectedVolume = recentPosition.Open.Quantity * recentPosition.Open.Price;
|
|
result.Should().Be(expectedVolume);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetLast24HVolumeTraded_WithOldTrades_ExcludesOldVolume()
|
|
{
|
|
// Arrange
|
|
var oldPosition = CreateTestPosition();
|
|
oldPosition.Open.Date = DateTime.UtcNow.AddHours(-48); // Outside 24 hours
|
|
var positions = new Dictionary<Guid, Position> { { oldPosition.Identifier, oldPosition } };
|
|
|
|
// Act
|
|
var result = TradingBox.GetLast24HVolumeTraded(positions);
|
|
|
|
// Assert
|
|
result.Should().Be(0m);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetWinLossCount_WithEmptyPositions_ReturnsZeros()
|
|
{
|
|
// Arrange
|
|
var positions = new List<Position>();
|
|
|
|
// Act
|
|
var result = TradingBox.GetWinLossCount(positions);
|
|
|
|
// Assert
|
|
result.Wins.Should().Be(0);
|
|
result.Losses.Should().Be(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetWinLossCount_WithProfitablePositions_CountsWins()
|
|
{
|
|
// Arrange
|
|
var winningPosition = CreateTestPosition();
|
|
winningPosition.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>
|
|
{
|
|
new Tuple<decimal, decimal>(0.001m, 50000m), // Open
|
|
new Tuple<decimal, decimal>(-0.001m, 52000m) // Close at profit
|
|
}, TradeDirection.Long);
|
|
var positions = new List<Position> { winningPosition };
|
|
|
|
// Act
|
|
var result = TradingBox.GetWinLossCount(positions);
|
|
|
|
// Assert
|
|
result.Wins.Should().Be(1);
|
|
result.Losses.Should().Be(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetWinLossCount_WithLosingPositions_CountsLosses()
|
|
{
|
|
// Arrange
|
|
var losingPosition = CreateTestPosition();
|
|
losingPosition.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>
|
|
{
|
|
new Tuple<decimal, decimal>(0.001m, 50000m), // Open
|
|
new Tuple<decimal, decimal>(-0.001m, 48000m) // Close at loss
|
|
}, TradeDirection.Long);
|
|
var positions = new List<Position> { losingPosition };
|
|
|
|
// Act
|
|
var result = TradingBox.GetWinLossCount(positions);
|
|
|
|
// Assert
|
|
result.Wins.Should().Be(0);
|
|
result.Losses.Should().Be(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetTotalRealizedPnL_WithEmptyPositions_ReturnsZero()
|
|
{
|
|
// Arrange
|
|
var positions = new Dictionary<Guid, Position>();
|
|
|
|
// Act
|
|
var result = TradingBox.GetTotalRealizedPnL(positions);
|
|
|
|
// Assert
|
|
result.Should().Be(0m);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetTotalRealizedPnL_WithValidPositions_SumsRealizedPnL()
|
|
{
|
|
// Arrange
|
|
var position1 = CreateTestPosition();
|
|
position1.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
|
{
|
|
Realized = 100m
|
|
};
|
|
|
|
var position2 = CreateTestPosition();
|
|
position2.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
|
{
|
|
Realized = 50m
|
|
};
|
|
|
|
var positions = new Dictionary<Guid, Position>
|
|
{
|
|
{ position1.Identifier, position1 },
|
|
{ position2.Identifier, position2 }
|
|
};
|
|
|
|
// Act
|
|
var result = TradingBox.GetTotalRealizedPnL(positions);
|
|
|
|
// Assert
|
|
result.Should().Be(150m);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetTotalNetPnL_WithEmptyPositions_ReturnsZero()
|
|
{
|
|
// Arrange
|
|
var positions = new Dictionary<Guid, Position>();
|
|
|
|
// Act
|
|
var result = TradingBox.GetTotalNetPnL(positions);
|
|
|
|
// Assert
|
|
result.Should().Be(0m);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetTotalNetPnL_WithValidPositions_SumsNetPnL()
|
|
{
|
|
// Arrange
|
|
var position1 = CreateTestPosition();
|
|
position1.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
|
{
|
|
Net = 80m // After fees
|
|
};
|
|
|
|
var position2 = CreateTestPosition();
|
|
position2.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
|
{
|
|
Net = 40m
|
|
};
|
|
|
|
var positions = new Dictionary<Guid, Position>
|
|
{
|
|
{ position1.Identifier, position1 },
|
|
{ position2.Identifier, position2 }
|
|
};
|
|
|
|
// Act
|
|
var result = TradingBox.GetTotalNetPnL(positions);
|
|
|
|
// Assert
|
|
result.Should().Be(120m);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetWinRate_WithEmptyPositions_ReturnsZero()
|
|
{
|
|
// Arrange
|
|
var positions = new Dictionary<Guid, Position>();
|
|
|
|
// Act
|
|
var result = TradingBox.GetWinRate(positions);
|
|
|
|
// Assert
|
|
result.Should().Be(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetWinRate_WithProfitablePositions_CalculatesPercentage()
|
|
{
|
|
// Arrange
|
|
var winningPosition1 = CreateTestPosition();
|
|
winningPosition1.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
|
{
|
|
Realized = 100m
|
|
};
|
|
|
|
var winningPosition2 = CreateTestPosition();
|
|
winningPosition2.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
|
{
|
|
Realized = 50m
|
|
};
|
|
|
|
var losingPosition = CreateTestPosition();
|
|
losingPosition.ProfitAndLoss = new ProfitAndLoss(new List<Tuple<decimal, decimal>>(), TradeDirection.Long)
|
|
{
|
|
Realized = -25m
|
|
};
|
|
|
|
var positions = new Dictionary<Guid, Position>
|
|
{
|
|
{ winningPosition1.Identifier, winningPosition1 },
|
|
{ winningPosition2.Identifier, winningPosition2 },
|
|
{ losingPosition.Identifier, losingPosition }
|
|
};
|
|
|
|
// Act
|
|
var result = TradingBox.GetWinRate(positions);
|
|
|
|
// Assert - 2 wins out of 3 positions = 66%
|
|
result.Should().Be(67); // Rounded up
|
|
}
|
|
|
|
[Fact]
|
|
public void GetTotalFees_WithEmptyPositions_ReturnsZero()
|
|
{
|
|
// Arrange
|
|
var positions = new Dictionary<Guid, Position>();
|
|
|
|
// Act
|
|
var result = TradingBox.GetTotalFees(positions);
|
|
|
|
// Assert
|
|
result.Should().Be(0m);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetTotalFees_WithValidPositions_SumsAllFees()
|
|
{
|
|
// Arrange
|
|
var position1 = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m);
|
|
var position2 = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m);
|
|
|
|
var positions = new Dictionary<Guid, Position>
|
|
{
|
|
{ position1.Identifier, position1 },
|
|
{ position2.Identifier, position2 }
|
|
};
|
|
|
|
// Act
|
|
var result = TradingBox.GetTotalFees(positions);
|
|
|
|
// Assert - Each position has fees, so total should be sum of both
|
|
result.Should().BeGreaterThan(0);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculatePositionFees_WithValidPosition_CalculatesFees()
|
|
{
|
|
// Arrange
|
|
var position = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m);
|
|
|
|
// Act
|
|
var result = TradingBox.CalculatePositionFees(position);
|
|
|
|
// Assert
|
|
result.Should().BeGreaterThan(0);
|
|
// UI fees should be calculated based on position size
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculatePositionFeesBreakdown_ReturnsUiAndGasFees()
|
|
{
|
|
// Arrange
|
|
var position = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m);
|
|
|
|
// Act
|
|
var result = TradingBox.CalculatePositionFeesBreakdown(position);
|
|
|
|
// Assert
|
|
result.uiFees.Should().BeGreaterThan(0);
|
|
result.gasFees.Should().Be(Constants.GMX.Config.GasFeePerTransaction);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateOpeningUiFees_WithPositionSize_CalculatesCorrectFee()
|
|
{
|
|
// Arrange
|
|
var positionSizeUsd = 1000m;
|
|
|
|
// Act
|
|
var result = TradingBox.CalculateOpeningUiFees(positionSizeUsd);
|
|
|
|
// Assert
|
|
result.Should().Be(positionSizeUsd * Constants.GMX.Config.UiFeeRate);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateClosingUiFees_WithPositionSize_CalculatesCorrectFee()
|
|
{
|
|
// Arrange
|
|
var positionSizeUsd = 1000m;
|
|
|
|
// Act
|
|
var result = TradingBox.CalculateClosingUiFees(positionSizeUsd);
|
|
|
|
// Assert
|
|
result.Should().Be(positionSizeUsd * Constants.GMX.Config.UiFeeRate);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateOpeningGasFees_ReturnsFixedAmount()
|
|
{
|
|
// Act
|
|
var result = TradingBox.CalculateOpeningGasFees();
|
|
|
|
// Assert
|
|
result.Should().Be(Constants.GMX.Config.GasFeePerTransaction);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetVolumeForPosition_WithOpenPosition_IncludesOpenVolume()
|
|
{
|
|
// Arrange
|
|
var position = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m);
|
|
|
|
// Act
|
|
var result = TradingBox.GetVolumeForPosition(position);
|
|
|
|
// Assert
|
|
var expectedVolume = 50000m * 0.001m * 1m; // price * quantity * leverage
|
|
result.Should().Be(expectedVolume);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetVolumeForPosition_WithClosedPosition_IncludesAllTradeVolumes()
|
|
{
|
|
// Arrange
|
|
var position = CreateTestPosition(openPrice: 50000m, quantity: 0.001m, leverage: 1m, isClosed: true, closePrice: 52000m);
|
|
|
|
// Act
|
|
var result = TradingBox.GetVolumeForPosition(position);
|
|
|
|
// Assert
|
|
var openVolume = 50000m * 0.001m * 1m;
|
|
var closeVolume = 52000m * 0.001m * 1m;
|
|
var expectedTotal = openVolume + closeVolume;
|
|
result.Should().Be(expectedTotal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1000, 0.1)] // 0.1% fee rate
|
|
[InlineData(10000, 1.0)] // 1.0 fee
|
|
[InlineData(100000, 10.0)] // 10.0 fee
|
|
public void CalculateOpeningUiFees_WithDifferentSizes_CalculatesProportionally(decimal positionSize, decimal expectedFee)
|
|
{
|
|
// Act
|
|
var result = TradingBox.CalculateOpeningUiFees(positionSize);
|
|
|
|
// Assert
|
|
result.Should().Be(expectedFee);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(TradeDirection.Long, 50000, 0.001, 1, 50)] // Long position volume
|
|
[InlineData(TradeDirection.Short, 50000, 0.001, 2, 100)] // Short position with leverage
|
|
public void GetVolumeForPosition_CalculatesCorrectVolume(TradeDirection direction, decimal price, decimal quantity, decimal leverage, decimal expectedVolume)
|
|
{
|
|
// Arrange
|
|
var position = CreateTestPosition(openPrice: price, quantity: quantity, leverage: leverage, direction: direction);
|
|
|
|
// Act
|
|
var result = TradingBox.GetVolumeForPosition(position);
|
|
|
|
// Assert
|
|
result.Should().Be(expectedVolume);
|
|
}
|
|
}
|