Add more tests + Log pnl for each backtest
This commit is contained in:
737
src/Managing.Domain.Tests/BacktestScorerTests.cs
Normal file
737
src/Managing.Domain.Tests/BacktestScorerTests.cs
Normal file
@@ -0,0 +1,737 @@
|
||||
using FluentAssertions;
|
||||
using Managing.Domain.Backtests;
|
||||
using Xunit;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Domain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for BacktestScorer class - the core algorithm that ranks trading strategies.
|
||||
/// Critical for ensuring correct strategy evaluation and selection.
|
||||
/// Covers component scores, penalties, early exits, and integration scenarios.
|
||||
/// </summary>
|
||||
public class BacktestScorerTests
|
||||
{
|
||||
private static readonly DateTime TestStartDate = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
private static readonly DateTime TestEndDate = new(2024, 3, 1, 0, 0, 0, DateTimeKind.Utc); // 60 days
|
||||
|
||||
#region Test Data Builders
|
||||
|
||||
private static BacktestScoringParams CreateBasicProfitableParams()
|
||||
{
|
||||
return new BacktestScoringParams(
|
||||
sharpeRatio: 0.02, // 2.0 after *100
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 60,
|
||||
totalPnL: 1000,
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5),
|
||||
maxDrawdown: 500,
|
||||
initialBalance: 10000,
|
||||
tradingBalance: 10000,
|
||||
startDate: TestStartDate,
|
||||
endDate: TestEndDate,
|
||||
timeframe: Timeframe.OneHour,
|
||||
moneyManagement: new LightMoneyManagement
|
||||
{
|
||||
StopLoss = 0.05m,
|
||||
TakeProfit = 0.10m
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static List<ScoringCheck> GetEarlyExitChecks(BacktestScoringResult result)
|
||||
{
|
||||
return result.Checks.Where(c => c.IsEarlyExit).ToList();
|
||||
}
|
||||
|
||||
private static List<ScoringCheck> GetComponentScores(BacktestScoringResult result)
|
||||
{
|
||||
return result.Checks.Where(c => !c.IsEarlyExit && !c.IsPenalty).ToList();
|
||||
}
|
||||
|
||||
private static List<ScoringCheck> GetPenaltyChecks(BacktestScoringResult result)
|
||||
{
|
||||
return result.Checks.Where(c => c.IsPenalty).ToList();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Early Exit Conditions Tests
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalScore_WithNoTrades_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 60,
|
||||
totalPnL: 1000,
|
||||
fees: 50,
|
||||
tradeCount: 0, // No trades
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateTotalScore(parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateDetailedScore_WithNoTrades_HasEarlyExitCheck()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 60,
|
||||
totalPnL: 1000,
|
||||
fees: 50,
|
||||
tradeCount: 0, // No trades
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().Be(0);
|
||||
var earlyExits = GetEarlyExitChecks(result);
|
||||
earlyExits.Should().NotBeEmpty();
|
||||
earlyExits.Should().Contain(e => e.Message.Contains("No trading positions"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalScore_WithNegativePnL_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 60,
|
||||
totalPnL: -500, // Negative PnL
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateTotalScore(parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateDetailedScore_WithNegativePnL_HasEarlyExitCheck()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 60,
|
||||
totalPnL: -500, // Negative PnL
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().Be(0);
|
||||
var earlyExits = GetEarlyExitChecks(result);
|
||||
earlyExits.Should().Contain(e => e.Message.Contains("negative"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalScore_WithZeroPnL_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 60,
|
||||
totalPnL: 0, // Zero PnL
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateTotalScore(parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalScore_WhenUnderperformsHodlByMoreThan2Percent_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 5, // Underperforms by 3%
|
||||
hodlPercentage: 8,
|
||||
winRate: 60,
|
||||
totalPnL: 500,
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateTotalScore(parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateDetailedScore_WhenUnderperformsHodl_HasEarlyExitCheck()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 4, // Underperforms by 4%
|
||||
hodlPercentage: 8,
|
||||
winRate: 60,
|
||||
totalPnL: 400,
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().Be(0);
|
||||
var earlyExits = GetEarlyExitChecks(result);
|
||||
earlyExits.Should().Contain(e => e.Message.Contains("underperforms HODL"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalScore_WhenUnderperformsHodlByLessThan2Percent_DoesNotEarlyExit()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 6, // Underperforms by 1% (within tolerance)
|
||||
hodlPercentage: 7,
|
||||
winRate: 60,
|
||||
totalPnL: 600,
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateTotalScore(parameters);
|
||||
|
||||
// Assert
|
||||
result.Should().BeGreaterThan(0); // Should not early exit
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Growth Score Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(-5)] // Negative growth
|
||||
[InlineData(0)] // Zero growth
|
||||
[InlineData(5)] // Low growth
|
||||
[InlineData(10)] // Moderate growth
|
||||
[InlineData(20)] // Good growth
|
||||
[InlineData(30)] // Excellent growth
|
||||
public void CalculateTotalScore_WithDifferentGrowthPercentages_ReflectsGrowth(double growth)
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: growth,
|
||||
hodlPercentage: 0, // Ensure HODL comparison doesn't cause early exit
|
||||
winRate: 60,
|
||||
totalPnL: growth > 0 ? 1000 : -100, // Negative PnL for negative growth
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateTotalScore(parameters);
|
||||
|
||||
// Assert
|
||||
if (growth <= 0)
|
||||
{
|
||||
result.Should().BeLessThanOrEqualTo(20); // Very low score
|
||||
}
|
||||
else if (growth >= 20)
|
||||
{
|
||||
result.Should().BeGreaterThan(20); // Good score (adjusted threshold)
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sharpe Ratio Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.00, 0)] // 0.0 Sharpe = 0%
|
||||
[InlineData(0.01, 25)] // 1.0 Sharpe (after *100) = 25% of max
|
||||
[InlineData(0.02, 50)] // 2.0 Sharpe = 50% of max
|
||||
[InlineData(0.04, 100)] // 4.0 Sharpe = 100% (max)
|
||||
[InlineData(0.05, 100)] // 5.0 Sharpe = 100% (capped)
|
||||
public void SharpeRatioScore_WithDifferentRatios_ScalesCorrectly(double sharpe, double expectedPercentage)
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: sharpe,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 60,
|
||||
totalPnL: 1000,
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
var sharpeCheck = GetComponentScores(result).FirstOrDefault(c => c.Component == "SharpeRatio");
|
||||
sharpeCheck.Should().NotBeNull();
|
||||
sharpeCheck.Score.Should().BeApproximately(expectedPercentage, 5);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HODL Comparison Tests
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalScore_WhenStrategyOutperformsHodlBy5Percent_GetsHighScore()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 15,
|
||||
hodlPercentage: 10, // Outperforms by 5%
|
||||
winRate: 60,
|
||||
totalPnL: 1500,
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
var hodlCheck = GetComponentScores(result).FirstOrDefault(c => c.Component == "HodlComparison");
|
||||
hodlCheck.Should().NotBeNull();
|
||||
hodlCheck.Score.Should().BeApproximately(100, 0.1); // Max score for 5%+ outperformance (allow floating point precision)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalScore_WhenStrategyMatchesHodl_GetsModerateScore()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 10, // Matches HODL
|
||||
winRate: 60,
|
||||
totalPnL: 1000,
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
var hodlCheck = GetComponentScores(result).FirstOrDefault(c => c.Component == "HodlComparison");
|
||||
hodlCheck.Should().NotBeNull();
|
||||
hodlCheck.Score.Should().BeInRange(30, 60);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Win Rate Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(70, 50)] // 70% win rate, 50 trades = good
|
||||
[InlineData(50, 50)] // 50% win rate, 50 trades = moderate
|
||||
[InlineData(30, 50)] // 30% win rate, 50 trades = low (penalty)
|
||||
public void CalculateTotalScore_WithDifferentWinRates_ReflectsPerformance(double winRate, int tradeCount)
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: winRate,
|
||||
totalPnL: 1000,
|
||||
fees: 50,
|
||||
tradeCount: tradeCount,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
if (winRate < 30)
|
||||
{
|
||||
GetPenaltyChecks(result).Should().Contain(p => p.Component.Contains("Win Rate"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalScore_WithFewTrades_ReducesWinRateSignificance()
|
||||
{
|
||||
// Arrange
|
||||
var parameters1 = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 70,
|
||||
totalPnL: 1000,
|
||||
fees: 50,
|
||||
tradeCount: 8, // Few trades
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
var parameters2 = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 70,
|
||||
totalPnL: 1000,
|
||||
fees: 50,
|
||||
tradeCount: 50, // Many trades
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result1 = BacktestScorer.CalculateDetailedScore(parameters1);
|
||||
var result2 = BacktestScorer.CalculateDetailedScore(parameters2);
|
||||
|
||||
// Assert
|
||||
var winRateScore1 = GetComponentScores(result1).First(c => c.Component == "WinRate").Score;
|
||||
var winRateScore2 = GetComponentScores(result2).First(c => c.Component == "WinRate").Score;
|
||||
|
||||
// Win rate score should be lower with fewer trades (significance factor)
|
||||
winRateScore1.Should().BeLessThan(winRateScore2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Trade Count Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(3, 0)] // Less than 5 trades = 0 points
|
||||
[InlineData(5, 0)] // 5 trades = 0 points (minimum)
|
||||
[InlineData(10, 50)] // 10 trades = 50 points
|
||||
[InlineData(50, 100)] // 50 trades = 100 points (optimal)
|
||||
[InlineData(100, 100)] // 100 trades = 100 points (capped)
|
||||
public void TradeCountScore_WithDifferentCounts_ScalesCorrectly(int tradeCount, double expectedScore)
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 60,
|
||||
totalPnL: 1000,
|
||||
fees: 50,
|
||||
tradeCount: tradeCount,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
var tradeCountCheck = GetComponentScores(result).FirstOrDefault(c => c.Component == "TradeCount");
|
||||
tradeCountCheck.Should().NotBeNull();
|
||||
tradeCountCheck.Score.Should().BeApproximately(expectedScore, 5);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Risk-Adjusted Return Tests
|
||||
|
||||
[Fact]
|
||||
public void RiskAdjustedReturnScore_WithExcellentRiskReward_Gets100Points()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 30, // 30% of balance
|
||||
hodlPercentage: 10,
|
||||
winRate: 60,
|
||||
totalPnL: 3000,
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5),
|
||||
maxDrawdown: 1000, // 10% of balance
|
||||
tradingBalance: 10000
|
||||
// Risk/Reward ratio = 30% / 10% = 3:1 (excellent)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
var riskCheck = GetComponentScores(result).FirstOrDefault(c => c.Component == "RiskAdjustedReturn");
|
||||
riskCheck.Should().NotBeNull();
|
||||
riskCheck.Score.Should().Be(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RiskAdjustedReturnScore_WithPoorRiskReward_GetsLowScore()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 5, // 5% of balance
|
||||
hodlPercentage: 2,
|
||||
winRate: 60,
|
||||
totalPnL: 500,
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5),
|
||||
maxDrawdown: 1000, // 10% of balance
|
||||
tradingBalance: 10000
|
||||
// Risk/Reward ratio = 5% / 10% = 0.5:1 (poor)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
var riskCheck = GetComponentScores(result).FirstOrDefault(c => c.Component == "RiskAdjustedReturn");
|
||||
riskCheck.Should().NotBeNull();
|
||||
riskCheck.Score.Should().BeLessThan(50);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fees Impact Tests
|
||||
|
||||
[Fact]
|
||||
public void FeesImpactScore_WithLowFees_GetsHighScore()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 60,
|
||||
totalPnL: 1000,
|
||||
fees: 50, // 5% of PnL
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
var feesCheck = GetComponentScores(result).FirstOrDefault(c => c.Component == "FeesImpact");
|
||||
feesCheck.Should().NotBeNull();
|
||||
feesCheck.Score.Should().BeGreaterThan(70);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FeesImpactScore_WithHighFees_GetsLowScore()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 60,
|
||||
totalPnL: 1000,
|
||||
fees: 300, // 30% of PnL
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
var feesCheck = GetComponentScores(result).FirstOrDefault(c => c.Component == "FeesImpact");
|
||||
feesCheck.Should().NotBeNull();
|
||||
feesCheck.Score.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Penalty Tests
|
||||
|
||||
[Fact]
|
||||
public void Penalties_WithLowWinRate_AppliesPenalty()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 25, // Below 30% threshold
|
||||
totalPnL: 1000,
|
||||
fees: 50,
|
||||
tradeCount: 50, // Enough trades for significance
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5)
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
GetPenaltyChecks(result).Should().Contain(p => p.Component == "Low Win Rate");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Penalties_WithHighDrawdown_AppliesPenalty()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.02,
|
||||
growthPercentage: 10,
|
||||
hodlPercentage: 5,
|
||||
winRate: 60,
|
||||
totalPnL: 1000,
|
||||
fees: 50,
|
||||
tradeCount: 50,
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(5),
|
||||
maxDrawdown: 3000, // 30% of balance
|
||||
tradingBalance: 10000
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
GetPenaltyChecks(result).Should().Contain(p => p.Component == "High Drawdown");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration Tests
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalScore_WithPerfectStrategy_GetsHighScore()
|
||||
{
|
||||
// Arrange - Create a near-perfect strategy
|
||||
var parameters = new BacktestScoringParams(
|
||||
sharpeRatio: 0.05, // Excellent Sharpe (5.0)
|
||||
growthPercentage: 50, // Excellent growth
|
||||
hodlPercentage: 10, // Significantly outperforms HODL
|
||||
winRate: 75, // High win rate
|
||||
totalPnL: 5000,
|
||||
fees: 200, // Low fees (4% of PnL)
|
||||
tradeCount: 100, // Good sample size
|
||||
maxDrawdownRecoveryTime: TimeSpan.FromDays(2), // Fast recovery
|
||||
maxDrawdown: 1000, // Low drawdown relative to PnL (5:1 ratio)
|
||||
initialBalance: 10000,
|
||||
tradingBalance: 10000,
|
||||
startDate: TestStartDate,
|
||||
endDate: TestStartDate.AddDays(180), // Long test period
|
||||
timeframe: Timeframe.OneHour,
|
||||
moneyManagement: new LightMoneyManagement
|
||||
{
|
||||
StopLoss = 0.05m,
|
||||
TakeProfit = 0.15m // 3:1 risk/reward
|
||||
}
|
||||
);
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().BeGreaterThan(70); // Should get a high score
|
||||
GetEarlyExitChecks(result).Should().BeEmpty();
|
||||
GetComponentScores(result).Should().HaveCountGreaterThan(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalScore_IsDeterministic()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = CreateBasicProfitableParams();
|
||||
|
||||
// Act - Call multiple times
|
||||
var result1 = BacktestScorer.CalculateTotalScore(parameters);
|
||||
var result2 = BacktestScorer.CalculateTotalScore(parameters);
|
||||
var result3 = BacktestScorer.CalculateTotalScore(parameters);
|
||||
|
||||
// Assert - Should always return the same score
|
||||
result1.Should().Be(result2);
|
||||
result2.Should().Be(result3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateDetailedScore_ScoreIsClampedBetween0And100()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = CreateBasicProfitableParams();
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().BeInRange(0, 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateDetailedScore_ComponentScoresHaveCorrectStructure()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = CreateBasicProfitableParams();
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
result.Checks.Should().NotBeEmpty();
|
||||
|
||||
foreach (var check in result.Checks)
|
||||
{
|
||||
check.Component.Should().NotBeNullOrEmpty();
|
||||
check.Message.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateDetailedScore_GeneratesSummaryMessage()
|
||||
{
|
||||
// Arrange
|
||||
var parameters = CreateBasicProfitableParams();
|
||||
|
||||
// Act
|
||||
var result = BacktestScorer.CalculateDetailedScore(parameters);
|
||||
|
||||
// Assert
|
||||
result.SummaryMessage.Should().NotBeNullOrEmpty();
|
||||
result.SummaryMessage.Should().Contain("Final Score");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
489
src/Managing.Domain.Tests/CandleHelpersTests.cs
Normal file
489
src/Managing.Domain.Tests/CandleHelpersTests.cs
Normal file
@@ -0,0 +1,489 @@
|
||||
using FluentAssertions;
|
||||
using Managing.Domain.Candles;
|
||||
using Xunit;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Domain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for CandleHelpers static utility class.
|
||||
/// Covers time calculations, intervals, grain keys, and candle boundary logic.
|
||||
/// Critical for ensuring accurate candle fetching, bot synchronization, and backtest timing.
|
||||
/// </summary>
|
||||
public class CandleHelpersTests
|
||||
{
|
||||
#region GetBaseIntervalInSeconds Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(Timeframe.FiveMinutes, 300)]
|
||||
[InlineData(Timeframe.FifteenMinutes, 900)]
|
||||
[InlineData(Timeframe.ThirtyMinutes, 1800)]
|
||||
[InlineData(Timeframe.OneHour, 3600)]
|
||||
[InlineData(Timeframe.FourHour, 14400)]
|
||||
[InlineData(Timeframe.OneDay, 86400)]
|
||||
public void GetBaseIntervalInSeconds_WithValidTimeframe_ReturnsCorrectSeconds(Timeframe timeframe, int expectedSeconds)
|
||||
{
|
||||
// Act
|
||||
var result = CandleHelpers.GetBaseIntervalInSeconds(timeframe);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedSeconds);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetUnixInterval Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(Timeframe.FiveMinutes, 300)]
|
||||
[InlineData(Timeframe.FifteenMinutes, 900)]
|
||||
[InlineData(Timeframe.OneHour, 3600)]
|
||||
[InlineData(Timeframe.FourHour, 14400)]
|
||||
[InlineData(Timeframe.OneDay, 86400)]
|
||||
public void GetUnixInterval_WithValidTimeframe_ReturnsCorrectInterval(Timeframe timeframe, int expectedInterval)
|
||||
{
|
||||
// Act
|
||||
var result = timeframe.GetUnixInterval();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedInterval);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUnixInterval_WithThirtyMinutes_ThrowsNotImplementedException()
|
||||
{
|
||||
// Act
|
||||
Action act = () => Timeframe.ThirtyMinutes.GetUnixInterval();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<NotImplementedException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetIntervalInMinutes Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(Timeframe.FiveMinutes, 1.0)] // 300 / 5 / 60 = 1 minute
|
||||
[InlineData(Timeframe.FifteenMinutes, 3.0)] // 900 / 5 / 60 = 3 minutes
|
||||
[InlineData(Timeframe.ThirtyMinutes, 6.0)] // 1800 / 5 / 60 = 6 minutes
|
||||
[InlineData(Timeframe.OneHour, 12.0)] // 3600 / 5 / 60 = 12 minutes
|
||||
[InlineData(Timeframe.FourHour, 48.0)] // 14400 / 5 / 60 = 48 minutes
|
||||
[InlineData(Timeframe.OneDay, 288.0)] // 86400 / 5 / 60 = 288 minutes
|
||||
public void GetIntervalInMinutes_WithValidTimeframe_ReturnsOneFifthOfCandleDuration(Timeframe timeframe, double expectedMinutes)
|
||||
{
|
||||
// Act
|
||||
var result = CandleHelpers.GetIntervalInMinutes(timeframe);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedMinutes);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetIntervalFromTimeframe Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(Timeframe.FiveMinutes, 60000)] // 300 / 5 * 1000 = 60000 ms
|
||||
[InlineData(Timeframe.FifteenMinutes, 180000)] // 900 / 5 * 1000 = 180000 ms
|
||||
[InlineData(Timeframe.OneHour, 720000)] // 3600 / 5 * 1000 = 720000 ms
|
||||
public void GetIntervalFromTimeframe_ReturnsMillisecondsForOneFifthOfCandleDuration(Timeframe timeframe, int expectedMilliseconds)
|
||||
{
|
||||
// Act
|
||||
var result = CandleHelpers.GetIntervalFromTimeframe(timeframe);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedMilliseconds);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetMinimalDays Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(Timeframe.FiveMinutes, -1)]
|
||||
[InlineData(Timeframe.FifteenMinutes, -5)]
|
||||
[InlineData(Timeframe.ThirtyMinutes, -10)]
|
||||
[InlineData(Timeframe.OneHour, -30)]
|
||||
[InlineData(Timeframe.FourHour, -60)]
|
||||
[InlineData(Timeframe.OneDay, -360)]
|
||||
public void GetMinimalDays_WithValidTimeframe_ReturnsCorrectNegativeDays(Timeframe timeframe, double expectedDays)
|
||||
{
|
||||
// Act
|
||||
var result = CandleHelpers.GetMinimalDays(timeframe);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedDays);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetBotPreloadSinceFromTimeframe Tests
|
||||
|
||||
[Fact]
|
||||
public void GetBotPreloadSinceFromTimeframe_WithFiveMinutes_ReturnsOneDayAgo()
|
||||
{
|
||||
// Arrange
|
||||
var before = DateTime.UtcNow.AddDays(-1);
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetBotPreloadSinceFromTimeframe(Timeframe.FiveMinutes);
|
||||
|
||||
// Assert
|
||||
var after = DateTime.UtcNow.AddDays(-1);
|
||||
result.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBotPreloadSinceFromTimeframe_WithOneHour_Returns30DaysAgo()
|
||||
{
|
||||
// Arrange
|
||||
var before = DateTime.UtcNow.AddDays(-30);
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetBotPreloadSinceFromTimeframe(Timeframe.OneHour);
|
||||
|
||||
// Assert
|
||||
var after = DateTime.UtcNow.AddDays(-30);
|
||||
result.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBotPreloadSinceFromTimeframe_WithOneDay_Returns360DaysAgo()
|
||||
{
|
||||
// Arrange
|
||||
var before = DateTime.UtcNow.AddDays(-360);
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetBotPreloadSinceFromTimeframe(Timeframe.OneDay);
|
||||
|
||||
// Assert
|
||||
var after = DateTime.UtcNow.AddDays(-360);
|
||||
result.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetPreloadSinceFromTimeframe Tests
|
||||
|
||||
[Fact]
|
||||
public void GetPreloadSinceFromTimeframe_WithFiveMinutes_ReturnsOneDayAgo()
|
||||
{
|
||||
// Arrange
|
||||
var expectedDays = -1;
|
||||
var before = DateTime.UtcNow.AddDays(expectedDays);
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetPreloadSinceFromTimeframe(Timeframe.FiveMinutes);
|
||||
|
||||
// Assert
|
||||
var after = DateTime.UtcNow.AddDays(expectedDays);
|
||||
result.Should().BeOnOrAfter(before).And.BeOnOrBefore(after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPreloadSinceFromTimeframe_UsesGetMinimalDays()
|
||||
{
|
||||
// Arrange
|
||||
var timeframe = Timeframe.OneHour;
|
||||
var minimalDays = CandleHelpers.GetMinimalDays(timeframe);
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetPreloadSinceFromTimeframe(timeframe);
|
||||
|
||||
// Assert
|
||||
var expectedBefore = DateTime.UtcNow.AddDays(minimalDays);
|
||||
var expectedAfter = DateTime.UtcNow.AddDays(minimalDays);
|
||||
result.Should().BeOnOrAfter(expectedBefore.AddSeconds(-1)).And.BeOnOrBefore(expectedAfter.AddSeconds(1));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetCandleStoreGrainKey & ParseCandleStoreGrainKey Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(TradingExchanges.Binance, Ticker.BTC, Timeframe.OneHour, "Binance-BTC-OneHour")]
|
||||
[InlineData(TradingExchanges.Kraken, Ticker.ETH, Timeframe.FiveMinutes, "Kraken-ETH-FiveMinutes")]
|
||||
[InlineData(TradingExchanges.GmxV2, Ticker.SOL, Timeframe.OneDay, "GmxV2-SOL-OneDay")]
|
||||
public void GetCandleStoreGrainKey_WithValidParameters_ReturnsCorrectKey(
|
||||
TradingExchanges exchange, Ticker ticker, Timeframe timeframe, string expectedKey)
|
||||
{
|
||||
// Act
|
||||
var result = CandleHelpers.GetCandleStoreGrainKey(exchange, ticker, timeframe);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedKey);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Binance-BTC-OneHour", TradingExchanges.Binance, Ticker.BTC, Timeframe.OneHour)]
|
||||
[InlineData("Kraken-ETH-FiveMinutes", TradingExchanges.Kraken, Ticker.ETH, Timeframe.FiveMinutes)]
|
||||
[InlineData("GmxV2-SOL-OneDay", TradingExchanges.GmxV2, Ticker.SOL, Timeframe.OneDay)]
|
||||
public void ParseCandleStoreGrainKey_WithValidKey_ReturnsCorrectComponents(
|
||||
string grainKey, TradingExchanges expectedExchange, Ticker expectedTicker, Timeframe expectedTimeframe)
|
||||
{
|
||||
// Act
|
||||
var (exchange, ticker, timeframe) = CandleHelpers.ParseCandleStoreGrainKey(grainKey);
|
||||
|
||||
// Assert
|
||||
exchange.Should().Be(expectedExchange);
|
||||
ticker.Should().Be(expectedTicker);
|
||||
timeframe.Should().Be(expectedTimeframe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetCandleStoreGrainKey_RoundTrip_PreservesOriginalValues()
|
||||
{
|
||||
// Arrange
|
||||
var originalExchange = TradingExchanges.Binance;
|
||||
var originalTicker = Ticker.BTC;
|
||||
var originalTimeframe = Timeframe.FifteenMinutes;
|
||||
|
||||
// Act
|
||||
var grainKey = CandleHelpers.GetCandleStoreGrainKey(originalExchange, originalTicker, originalTimeframe);
|
||||
var (parsedExchange, parsedTicker, parsedTimeframe) = CandleHelpers.ParseCandleStoreGrainKey(grainKey);
|
||||
|
||||
// Assert
|
||||
parsedExchange.Should().Be(originalExchange);
|
||||
parsedTicker.Should().Be(originalTicker);
|
||||
parsedTimeframe.Should().Be(originalTimeframe);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetNextExpectedCandleTime Tests
|
||||
|
||||
[Fact]
|
||||
public void GetNextExpectedCandleTime_WithFiveMinutes_AlignsToFiveMinuteBoundary()
|
||||
{
|
||||
// Arrange - Use a specific time to ensure predictable results
|
||||
var now = new DateTime(2024, 1, 1, 12, 3, 30, DateTimeKind.Utc); // 12:03:30
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetNextExpectedCandleTime(Timeframe.FiveMinutes, now);
|
||||
|
||||
// Assert
|
||||
// Next 5-minute boundary is 12:05:00, minus 1 second = 12:04:59
|
||||
var expected = new DateTime(2024, 1, 1, 12, 4, 59, DateTimeKind.Utc);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextExpectedCandleTime_WithFifteenMinutes_AlignsToFifteenMinuteBoundary()
|
||||
{
|
||||
// Arrange
|
||||
var now = new DateTime(2024, 1, 1, 12, 8, 0, DateTimeKind.Utc); // 12:08:00
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetNextExpectedCandleTime(Timeframe.FifteenMinutes, now);
|
||||
|
||||
// Assert
|
||||
// Next 15-minute boundary is 12:15:00, minus 1 second = 12:14:59
|
||||
var expected = new DateTime(2024, 1, 1, 12, 14, 59, DateTimeKind.Utc);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextExpectedCandleTime_WithOneHour_AlignsToHourBoundary()
|
||||
{
|
||||
// Arrange
|
||||
var now = new DateTime(2024, 1, 1, 12, 30, 0, DateTimeKind.Utc); // 12:30:00
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetNextExpectedCandleTime(Timeframe.OneHour, now);
|
||||
|
||||
// Assert
|
||||
// Next hour boundary is 13:00:00, minus 1 second = 12:59:59
|
||||
var expected = new DateTime(2024, 1, 1, 12, 59, 59, DateTimeKind.Utc);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextExpectedCandleTime_WithFourHour_AlignsToFourHourBoundary()
|
||||
{
|
||||
// Arrange
|
||||
var now = new DateTime(2024, 1, 1, 10, 0, 0, DateTimeKind.Utc); // 10:00:00
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetNextExpectedCandleTime(Timeframe.FourHour, now);
|
||||
|
||||
// Assert
|
||||
// Next 4-hour boundary is 12:00:00, minus 1 second = 11:59:59
|
||||
var expected = new DateTime(2024, 1, 1, 11, 59, 59, DateTimeKind.Utc);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextExpectedCandleTime_WithOneDay_AlignsToDayBoundary()
|
||||
{
|
||||
// Arrange
|
||||
var now = new DateTime(2024, 1, 1, 15, 0, 0, DateTimeKind.Utc); // Jan 1, 15:00
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetNextExpectedCandleTime(Timeframe.OneDay, now);
|
||||
|
||||
// Assert
|
||||
// Next day boundary is Jan 2 00:00:00, minus 1 second = Jan 1 23:59:59
|
||||
var expected = new DateTime(2024, 1, 1, 23, 59, 59, DateTimeKind.Utc);
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextExpectedCandleTime_WithoutNowParameter_UsesCurrentTime()
|
||||
{
|
||||
// Act
|
||||
var result = CandleHelpers.GetNextExpectedCandleTime(Timeframe.FiveMinutes);
|
||||
|
||||
// Assert
|
||||
result.Should().BeAfter(DateTime.UtcNow.AddMinutes(-1));
|
||||
result.Should().BeBefore(DateTime.UtcNow.AddMinutes(10));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetDueTimeForTimeframe Tests
|
||||
|
||||
[Fact]
|
||||
public void GetDueTimeForTimeframe_WithFiveMinutes_ReturnsTimeToNextBoundary()
|
||||
{
|
||||
// Arrange - Use a specific time
|
||||
var now = new DateTime(2024, 1, 1, 12, 3, 30, DateTimeKind.Utc); // 12:03:30
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetDueTimeForTimeframe(Timeframe.FiveMinutes, now);
|
||||
|
||||
// Assert
|
||||
// Next 5-minute boundary is 12:05:00, plus 1 second = 12:05:01
|
||||
// Time from 12:03:30 to 12:05:01 = 1 minute 31 seconds
|
||||
result.TotalSeconds.Should().BeApproximately(91, 1);
|
||||
result.Should().BePositive();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDueTimeForTimeframe_WithOneHour_ReturnsTimeToNextHourBoundary()
|
||||
{
|
||||
// Arrange
|
||||
var now = new DateTime(2024, 1, 1, 12, 30, 0, DateTimeKind.Utc); // 12:30:00
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetDueTimeForTimeframe(Timeframe.OneHour, now);
|
||||
|
||||
// Assert
|
||||
// Next hour boundary is 13:00:00, plus 1 second = 13:00:01
|
||||
// Time from 12:30:00 to 13:00:01 = 30 minutes 1 second
|
||||
result.TotalMinutes.Should().BeApproximately(30, 1);
|
||||
result.Should().BePositive();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDueTimeForTimeframe_ResultIsAlwaysPositive()
|
||||
{
|
||||
// Arrange - Test at various times
|
||||
var testTimes = new[]
|
||||
{
|
||||
new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2024, 1, 1, 23, 59, 0, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
foreach (var testTime in testTimes)
|
||||
{
|
||||
// Act
|
||||
var result = CandleHelpers.GetDueTimeForTimeframe(Timeframe.FifteenMinutes, testTime);
|
||||
|
||||
// Assert
|
||||
result.Should().BePositive($"time {testTime} should produce positive due time");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetDueTimeForTimeframe_WithFourHour_CalculatesCorrectDueTime()
|
||||
{
|
||||
// Arrange
|
||||
var now = new DateTime(2024, 1, 1, 10, 0, 0, DateTimeKind.Utc); // 10:00:00
|
||||
|
||||
// Act
|
||||
var result = CandleHelpers.GetDueTimeForTimeframe(Timeframe.FourHour, now);
|
||||
|
||||
// Assert
|
||||
// Next 4-hour boundary is 12:00:00, plus 1 second = 12:00:01
|
||||
// Time from 10:00:00 to 12:00:01 = 2 hours 1 second
|
||||
result.TotalHours.Should().BeApproximately(2, 0.1);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases and Integration Tests
|
||||
|
||||
[Fact]
|
||||
public void GetBaseIntervalInSeconds_AndGetUnixInterval_ReturnSameValuesForSupportedTimeframes()
|
||||
{
|
||||
// Arrange
|
||||
var supportedTimeframes = new[]
|
||||
{
|
||||
Timeframe.FiveMinutes,
|
||||
Timeframe.FifteenMinutes,
|
||||
Timeframe.OneHour,
|
||||
Timeframe.FourHour,
|
||||
Timeframe.OneDay
|
||||
};
|
||||
|
||||
foreach (var timeframe in supportedTimeframes)
|
||||
{
|
||||
// Act
|
||||
var baseInterval = CandleHelpers.GetBaseIntervalInSeconds(timeframe);
|
||||
var unixInterval = timeframe.GetUnixInterval();
|
||||
|
||||
// Assert
|
||||
baseInterval.Should().Be(unixInterval, $"{timeframe} should return consistent values");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetIntervalFromTimeframe_ReturnsConsistentlyOneFifthOfBaseInterval()
|
||||
{
|
||||
// Arrange
|
||||
var timeframes = new[]
|
||||
{
|
||||
Timeframe.FiveMinutes,
|
||||
Timeframe.FifteenMinutes,
|
||||
Timeframe.ThirtyMinutes,
|
||||
Timeframe.OneHour,
|
||||
Timeframe.FourHour,
|
||||
Timeframe.OneDay
|
||||
};
|
||||
|
||||
foreach (var timeframe in timeframes)
|
||||
{
|
||||
// Act
|
||||
var intervalMs = CandleHelpers.GetIntervalFromTimeframe(timeframe);
|
||||
var baseIntervalSeconds = CandleHelpers.GetBaseIntervalInSeconds(timeframe);
|
||||
var expectedIntervalMs = (baseIntervalSeconds / 5) * 1000;
|
||||
|
||||
// Assert
|
||||
intervalMs.Should().Be(expectedIntervalMs, $"{timeframe} should be 1/5th of base interval");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TimeCalculationMethods_AreConsistentWithEachOther()
|
||||
{
|
||||
// Arrange
|
||||
var timeframe = Timeframe.FifteenMinutes;
|
||||
|
||||
// Act
|
||||
var baseSeconds = CandleHelpers.GetBaseIntervalInSeconds(timeframe);
|
||||
var intervalMinutes = CandleHelpers.GetIntervalInMinutes(timeframe);
|
||||
var intervalMs = CandleHelpers.GetIntervalFromTimeframe(timeframe);
|
||||
|
||||
// Assert
|
||||
// intervalMinutes should be baseSeconds / 5 / 60
|
||||
intervalMinutes.Should().Be((double)baseSeconds / 5 / 60);
|
||||
|
||||
// intervalMs should be baseSeconds / 5 * 1000
|
||||
intervalMs.Should().Be((baseSeconds / 5) * 1000);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
946
src/Managing.Domain.Tests/PositionTests.cs
Normal file
946
src/Managing.Domain.Tests/PositionTests.cs
Normal file
@@ -0,0 +1,946 @@
|
||||
using FluentAssertions;
|
||||
using Managing.Domain.Trades;
|
||||
using Xunit;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Domain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Position entity calculation methods.
|
||||
/// Covers fee calculations, PnL methods, and position status checks.
|
||||
/// </summary>
|
||||
public class PositionTests : TradingBoxTests
|
||||
{
|
||||
#region CalculateTotalFees Tests
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalFees_WithNoFees_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.UiFees = 0m;
|
||||
position.GasFees = 0m;
|
||||
|
||||
// Act
|
||||
var result = position.CalculateTotalFees();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalFees_WithOnlyUiFees_ReturnsUiFees()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.UiFees = 10.5m;
|
||||
position.GasFees = 0m;
|
||||
|
||||
// Act
|
||||
var result = position.CalculateTotalFees();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(10.5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalFees_WithOnlyGasFees_ReturnsGasFees()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.UiFees = 0m;
|
||||
position.GasFees = 5.25m;
|
||||
|
||||
// Act
|
||||
var result = position.CalculateTotalFees();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(5.25m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalFees_WithBothFees_ReturnsSumOfBothFees()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.UiFees = 10.5m;
|
||||
position.GasFees = 5.25m;
|
||||
|
||||
// Act
|
||||
var result = position.CalculateTotalFees();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(15.75m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTotalFees_WithLargeValues_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.UiFees = 1234.567m;
|
||||
position.GasFees = 8765.432m;
|
||||
|
||||
// Act
|
||||
var result = position.CalculateTotalFees();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(9999.999m);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetPnLBeforeFees Tests
|
||||
|
||||
[Fact]
|
||||
public void GetPnLBeforeFees_WithNullProfitAndLoss_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = null;
|
||||
|
||||
// Act
|
||||
var result = position.GetPnLBeforeFees();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPnLBeforeFees_WithPositivePnL_ReturnsRealizedPnL()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Realized = 250.50m };
|
||||
|
||||
// Act
|
||||
var result = position.GetPnLBeforeFees();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(250.50m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPnLBeforeFees_WithNegativePnL_ReturnsRealizedPnL()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Realized = -125.75m };
|
||||
|
||||
// Act
|
||||
var result = position.GetPnLBeforeFees();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(-125.75m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPnLBeforeFees_WithZeroPnL_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Realized = 0m };
|
||||
|
||||
// Act
|
||||
var result = position.GetPnLBeforeFees();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0m);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetNetPnl Tests
|
||||
|
||||
[Fact]
|
||||
public void GetNetPnl_WithNullProfitAndLoss_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = null;
|
||||
|
||||
// Act
|
||||
var result = position.GetNetPnl();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNetPnl_WithProfitAndNoFees_ReturnsRealizedPnL()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Realized = 100m, Net = 100m };
|
||||
position.UiFees = 0m;
|
||||
position.GasFees = 0m;
|
||||
|
||||
// Act
|
||||
var result = position.GetNetPnl();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(100m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNetPnl_WithProfitAndFees_ReturnsNetAfterFees()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Realized = 100m, Net = 85m };
|
||||
position.UiFees = 10m;
|
||||
position.GasFees = 5m;
|
||||
|
||||
// Act
|
||||
var result = position.GetNetPnl();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(85m); // 100 - 10 - 5 = 85
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNetPnl_WithLossAndFees_ReturnsNegativeNet()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Realized = -50m, Net = -65m };
|
||||
position.UiFees = 10m;
|
||||
position.GasFees = 5m;
|
||||
|
||||
// Act
|
||||
var result = position.GetNetPnl();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(-65m); // -50 - 10 - 5 = -65
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNetPnl_WithBreakevenAndFees_ReturnsNegativeFromFees()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Realized = 0m, Net = -15m };
|
||||
position.UiFees = 10m;
|
||||
position.GasFees = 5m;
|
||||
|
||||
// Act
|
||||
var result = position.GetNetPnl();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(-15m); // 0 - 10 - 5 = -15
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNetPnl_WithHighPrecisionValues_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Realized = 123.456789m, Net = 111.111789m };
|
||||
position.UiFees = 10.345m;
|
||||
position.GasFees = 2m;
|
||||
|
||||
// Act
|
||||
var result = position.GetNetPnl();
|
||||
|
||||
// Assert
|
||||
result.Should().Be(111.111789m); // 123.456789 - 10.345 - 2 = 111.111789
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AddUiFees Tests
|
||||
|
||||
[Fact]
|
||||
public void AddUiFees_WithZeroInitialFees_AddsFeesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.UiFees = 0m;
|
||||
|
||||
// Act
|
||||
position.AddUiFees(10.5m);
|
||||
|
||||
// Assert
|
||||
position.UiFees.Should().Be(10.5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddUiFees_WithExistingFees_AccumulatesFeesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.UiFees = 10m;
|
||||
|
||||
// Act
|
||||
position.AddUiFees(5.5m);
|
||||
|
||||
// Assert
|
||||
position.UiFees.Should().Be(15.5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddUiFees_WithMultipleCalls_AccumulatesAllFees()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.UiFees = 0m;
|
||||
|
||||
// Act
|
||||
position.AddUiFees(5m);
|
||||
position.AddUiFees(10m);
|
||||
position.AddUiFees(2.5m);
|
||||
|
||||
// Assert
|
||||
position.UiFees.Should().Be(17.5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddUiFees_WithZeroValue_DoesNotChangeTotal()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.UiFees = 10m;
|
||||
|
||||
// Act
|
||||
position.AddUiFees(0m);
|
||||
|
||||
// Assert
|
||||
position.UiFees.Should().Be(10m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddUiFees_WithHighPrecision_MaintainsPrecision()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.UiFees = 1.123456m;
|
||||
|
||||
// Act
|
||||
position.AddUiFees(2.654321m);
|
||||
|
||||
// Assert
|
||||
position.UiFees.Should().Be(3.777777m);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AddGasFees Tests
|
||||
|
||||
[Fact]
|
||||
public void AddGasFees_WithZeroInitialFees_AddsFeesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.GasFees = 0m;
|
||||
|
||||
// Act
|
||||
position.AddGasFees(5.25m);
|
||||
|
||||
// Assert
|
||||
position.GasFees.Should().Be(5.25m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddGasFees_WithExistingFees_AccumulatesFeesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.GasFees = 10m;
|
||||
|
||||
// Act
|
||||
position.AddGasFees(7.5m);
|
||||
|
||||
// Assert
|
||||
position.GasFees.Should().Be(17.5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddGasFees_WithMultipleCalls_AccumulatesAllFees()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.GasFees = 0m;
|
||||
|
||||
// Act
|
||||
position.AddGasFees(3m);
|
||||
position.AddGasFees(5m);
|
||||
position.AddGasFees(1.5m);
|
||||
|
||||
// Assert
|
||||
position.GasFees.Should().Be(9.5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddGasFees_WithZeroValue_DoesNotChangeTotal()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.GasFees = 10m;
|
||||
|
||||
// Act
|
||||
position.AddGasFees(0m);
|
||||
|
||||
// Assert
|
||||
position.GasFees.Should().Be(10m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddGasFees_WithHighPrecision_MaintainsPrecision()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.GasFees = 0.123456m;
|
||||
|
||||
// Act
|
||||
position.AddGasFees(0.654321m);
|
||||
|
||||
// Assert
|
||||
position.GasFees.Should().Be(0.777777m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddBothFees_CalculatesTotalCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.UiFees = 0m;
|
||||
position.GasFees = 0m;
|
||||
|
||||
// Act
|
||||
position.AddUiFees(10m);
|
||||
position.AddGasFees(5m);
|
||||
position.AddUiFees(2.5m);
|
||||
position.AddGasFees(1.5m);
|
||||
|
||||
// Assert
|
||||
position.UiFees.Should().Be(12.5m);
|
||||
position.GasFees.Should().Be(6.5m);
|
||||
position.CalculateTotalFees().Should().Be(19m);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsFinished Tests
|
||||
|
||||
[Fact]
|
||||
public void IsFinished_WithFinishedStatus_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Finished);
|
||||
|
||||
// Act
|
||||
var result = position.IsFinished();
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFinished_WithCanceledStatus_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Canceled);
|
||||
|
||||
// Act
|
||||
var result = position.IsFinished();
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFinished_WithRejectedStatus_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Rejected);
|
||||
|
||||
// Act
|
||||
var result = position.IsFinished();
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFinished_WithFlippedStatus_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Flipped);
|
||||
|
||||
// Act
|
||||
var result = position.IsFinished();
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFinished_WithNewStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.New);
|
||||
|
||||
// Act
|
||||
var result = position.IsFinished();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFinished_WithFilledStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Filled);
|
||||
|
||||
// Act
|
||||
var result = position.IsFinished();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFinished_WithUpdatingStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Updating);
|
||||
|
||||
// Act
|
||||
var result = position.IsFinished();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsOpen Tests
|
||||
|
||||
[Fact]
|
||||
public void IsOpen_WithFilledStatus_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Filled);
|
||||
|
||||
// Act
|
||||
var result = position.IsOpen();
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOpen_WithNewStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.New);
|
||||
|
||||
// Act
|
||||
var result = position.IsOpen();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOpen_WithFinishedStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Finished);
|
||||
|
||||
// Act
|
||||
var result = position.IsOpen();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOpen_WithCanceledStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Canceled);
|
||||
|
||||
// Act
|
||||
var result = position.IsOpen();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOpen_WithRejectedStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Rejected);
|
||||
|
||||
// Act
|
||||
var result = position.IsOpen();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOpen_WithUpdatingStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Updating);
|
||||
|
||||
// Act
|
||||
var result = position.IsOpen();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOpen_WithFlippedStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Flipped);
|
||||
|
||||
// Act
|
||||
var result = position.IsOpen();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsInProfit Tests
|
||||
|
||||
[Fact]
|
||||
public void IsInProfit_WithNullProfitAndLoss_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = null;
|
||||
|
||||
// Act
|
||||
var result = position.IsInProfit();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInProfit_WithPositiveNet_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Net = 100m };
|
||||
|
||||
// Act
|
||||
var result = position.IsInProfit();
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInProfit_WithSmallPositiveNet_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Net = 0.01m };
|
||||
|
||||
// Act
|
||||
var result = position.IsInProfit();
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInProfit_WithZeroNet_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Net = 0m };
|
||||
|
||||
// Act
|
||||
var result = position.IsInProfit();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInProfit_WithNegativeNet_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Net = -50m };
|
||||
|
||||
// Act
|
||||
var result = position.IsInProfit();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsInProfit_WithSmallNegativeNet_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition();
|
||||
position.ProfitAndLoss = new ProfitAndLoss { Net = -0.01m };
|
||||
|
||||
// Act
|
||||
var result = position.IsInProfit();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsValidForMetrics Tests
|
||||
|
||||
[Fact]
|
||||
public void IsValidForMetrics_WithFilledStatus_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Filled);
|
||||
|
||||
// Act
|
||||
var result = position.IsValidForMetrics();
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForMetrics_WithFinishedStatus_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Finished);
|
||||
|
||||
// Act
|
||||
var result = position.IsValidForMetrics();
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForMetrics_WithFlippedStatus_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Flipped);
|
||||
|
||||
// Act
|
||||
var result = position.IsValidForMetrics();
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForMetrics_WithNewStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.New);
|
||||
|
||||
// Act
|
||||
var result = position.IsValidForMetrics();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForMetrics_WithCanceledStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Canceled);
|
||||
|
||||
// Act
|
||||
var result = position.IsValidForMetrics();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForMetrics_WithRejectedStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Rejected);
|
||||
|
||||
// Act
|
||||
var result = position.IsValidForMetrics();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsValidForMetrics_WithUpdatingStatus_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Updating);
|
||||
|
||||
// Act
|
||||
var result = position.IsValidForMetrics();
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Integration Tests - Combined Methods
|
||||
|
||||
[Fact]
|
||||
public void CompletePositionLifecycle_WithProfit_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange - Create a complete position with profit
|
||||
var position = CreateTestPosition(
|
||||
openPrice: 50000m,
|
||||
quantity: 0.1m,
|
||||
direction: TradeDirection.Long,
|
||||
positionStatus: PositionStatus.Finished
|
||||
);
|
||||
|
||||
position.ProfitAndLoss = new ProfitAndLoss
|
||||
{
|
||||
Realized = 500m, // $500 profit
|
||||
Net = 475m // $500 - $25 fees = $475
|
||||
};
|
||||
position.AddUiFees(15m);
|
||||
position.AddGasFees(10m);
|
||||
|
||||
// Act & Assert
|
||||
position.GetPnLBeforeFees().Should().Be(500m);
|
||||
position.CalculateTotalFees().Should().Be(25m);
|
||||
position.GetNetPnl().Should().Be(475m); // 500 - 15 - 10
|
||||
position.IsFinished().Should().BeTrue();
|
||||
position.IsOpen().Should().BeFalse();
|
||||
position.IsInProfit().Should().BeTrue();
|
||||
position.IsValidForMetrics().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletePositionLifecycle_WithLoss_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange - Create a complete position with loss
|
||||
var position = CreateTestPosition(
|
||||
openPrice: 50000m,
|
||||
quantity: 0.1m,
|
||||
direction: TradeDirection.Short,
|
||||
positionStatus: PositionStatus.Finished
|
||||
);
|
||||
|
||||
position.ProfitAndLoss = new ProfitAndLoss
|
||||
{
|
||||
Realized = -300m, // $300 loss
|
||||
Net = -325m // -$300 - $25 fees = -$325
|
||||
};
|
||||
position.AddUiFees(15m);
|
||||
position.AddGasFees(10m);
|
||||
|
||||
// Act & Assert
|
||||
position.GetPnLBeforeFees().Should().Be(-300m);
|
||||
position.CalculateTotalFees().Should().Be(25m);
|
||||
position.GetNetPnl().Should().Be(-325m); // -300 - 15 - 10
|
||||
position.IsFinished().Should().BeTrue();
|
||||
position.IsOpen().Should().BeFalse();
|
||||
position.IsInProfit().Should().BeFalse();
|
||||
position.IsValidForMetrics().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivePosition_WithFloatingProfit_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange - Active position (filled) with unrealized profit
|
||||
var position = CreateTestPosition(
|
||||
openPrice: 50000m,
|
||||
quantity: 0.1m,
|
||||
direction: TradeDirection.Long,
|
||||
positionStatus: PositionStatus.Filled
|
||||
);
|
||||
|
||||
position.ProfitAndLoss = new ProfitAndLoss
|
||||
{
|
||||
Realized = 0m, // Not yet realized
|
||||
Net = 200m // Unrealized/floating profit
|
||||
};
|
||||
position.AddUiFees(10m); // Opening fees
|
||||
|
||||
// Act & Assert
|
||||
position.GetPnLBeforeFees().Should().Be(0m); // Not realized yet
|
||||
position.CalculateTotalFees().Should().Be(10m);
|
||||
position.IsFinished().Should().BeFalse();
|
||||
position.IsOpen().Should().BeTrue();
|
||||
position.IsInProfit().Should().BeTrue(); // Based on Net
|
||||
position.IsValidForMetrics().Should().BeTrue(); // Filled is valid
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanceledPosition_WithoutTrades_ReturnsCorrectStatus()
|
||||
{
|
||||
// Arrange - Canceled position before execution
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Canceled);
|
||||
position.ProfitAndLoss = null; // No trades executed
|
||||
|
||||
// Act & Assert
|
||||
position.GetPnLBeforeFees().Should().Be(0m);
|
||||
position.GetNetPnl().Should().Be(0m);
|
||||
position.CalculateTotalFees().Should().Be(0m);
|
||||
position.IsFinished().Should().BeTrue();
|
||||
position.IsOpen().Should().BeFalse();
|
||||
position.IsInProfit().Should().BeFalse();
|
||||
position.IsValidForMetrics().Should().BeFalse(); // Canceled not valid
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlippedPosition_MaintainsValidStatus()
|
||||
{
|
||||
// Arrange - Position that was flipped (direction changed)
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Flipped);
|
||||
position.ProfitAndLoss = new ProfitAndLoss
|
||||
{
|
||||
Realized = 150m,
|
||||
Net = 130m
|
||||
};
|
||||
position.AddUiFees(12m);
|
||||
position.AddGasFees(8m);
|
||||
|
||||
// Act & Assert
|
||||
position.GetPnLBeforeFees().Should().Be(150m);
|
||||
position.GetNetPnl().Should().Be(130m); // 150 - 12 - 8
|
||||
position.CalculateTotalFees().Should().Be(20m);
|
||||
position.IsFinished().Should().BeTrue(); // Flipped is considered finished
|
||||
position.IsOpen().Should().BeFalse();
|
||||
position.IsValidForMetrics().Should().BeTrue(); // Flipped is valid for metrics
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BreakevenPosition_WithOnlyFees_ShowsLoss()
|
||||
{
|
||||
// Arrange - Position closed at breakeven price but loses to fees
|
||||
var position = CreateTestPosition(positionStatus: PositionStatus.Finished);
|
||||
position.ProfitAndLoss = new ProfitAndLoss
|
||||
{
|
||||
Realized = 0m, // No price difference
|
||||
Net = -20m // Loss due to fees
|
||||
};
|
||||
position.AddUiFees(12m);
|
||||
position.AddGasFees(8m);
|
||||
|
||||
// Act & Assert
|
||||
position.GetPnLBeforeFees().Should().Be(0m);
|
||||
position.GetNetPnl().Should().Be(-20m); // 0 - 12 - 8
|
||||
position.CalculateTotalFees().Should().Be(20m);
|
||||
position.IsInProfit().Should().BeFalse();
|
||||
position.IsFinished().Should().BeTrue();
|
||||
position.IsValidForMetrics().Should().BeTrue();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
471
src/Managing.Domain.Tests/RiskHelpersTests.cs
Normal file
471
src/Managing.Domain.Tests/RiskHelpersTests.cs
Normal file
@@ -0,0 +1,471 @@
|
||||
using FluentAssertions;
|
||||
using Managing.Domain.Shared.Helpers;
|
||||
using Xunit;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Domain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for RiskHelpers static utility methods.
|
||||
/// Covers SL/TP price calculations and confidence to risk level mapping.
|
||||
/// CRITICAL: These methods directly impact live trading risk management.
|
||||
/// </summary>
|
||||
public class RiskHelpersTests
|
||||
{
|
||||
// Test data builder for LightMoneyManagement
|
||||
private static LightMoneyManagement CreateMoneyManagement(
|
||||
decimal stopLoss = 0.02m, // 2% default
|
||||
decimal takeProfit = 0.04m, // 4% default
|
||||
decimal leverage = 1m)
|
||||
{
|
||||
return new LightMoneyManagement
|
||||
{
|
||||
Name = "TestMM",
|
||||
Timeframe = Timeframe.OneHour,
|
||||
StopLoss = stopLoss,
|
||||
TakeProfit = takeProfit,
|
||||
Leverage = leverage
|
||||
};
|
||||
}
|
||||
|
||||
#region GetStopLossPrice Tests
|
||||
|
||||
[Fact]
|
||||
public void GetStopLossPrice_WithLongPosition_CalculatesSLBelowEntry()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var entryPrice = 100m;
|
||||
var moneyManagement = CreateMoneyManagement(stopLoss: 0.02m); // 2%
|
||||
|
||||
// Act
|
||||
var stopLossPrice = RiskHelpers.GetStopLossPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
stopLossPrice.Should().Be(98m); // 100 - (100 * 0.02) = 98
|
||||
stopLossPrice.Should().BeLessThan(entryPrice, "SL should be below entry for Long position");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStopLossPrice_WithShortPosition_CalculatesSLAboveEntry()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Short;
|
||||
var entryPrice = 100m;
|
||||
var moneyManagement = CreateMoneyManagement(stopLoss: 0.02m); // 2%
|
||||
|
||||
// Act
|
||||
var stopLossPrice = RiskHelpers.GetStopLossPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
stopLossPrice.Should().Be(102m); // 100 + (100 * 0.02) = 102
|
||||
stopLossPrice.Should().BeGreaterThan(entryPrice, "SL should be above entry for Short position");
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> GetStopLossPriceLongTestData()
|
||||
{
|
||||
yield return new object[] { 100m, 0.01m, 99m }; // 1% SL
|
||||
yield return new object[] { 100m, 0.05m, 95m }; // 5% SL
|
||||
yield return new object[] { 100m, 0.10m, 90m }; // 10% SL
|
||||
yield return new object[] { 1000m, 0.02m, 980m }; // Larger price
|
||||
yield return new object[] { 50000m, 0.015m, 49250m }; // Crypto price with 1.5% SL
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetStopLossPriceLongTestData))]
|
||||
public void GetStopLossPrice_WithLongPosition_CalculatesCorrectSLForVariousPercentages(
|
||||
decimal entryPrice, decimal stopLossPercentage, decimal expectedSL)
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var moneyManagement = CreateMoneyManagement(stopLoss: stopLossPercentage);
|
||||
|
||||
// Act
|
||||
var stopLossPrice = RiskHelpers.GetStopLossPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
stopLossPrice.Should().BeApproximately(expectedSL, 0.01m);
|
||||
stopLossPrice.Should().BeLessThan(entryPrice);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> GetStopLossPriceShortTestData()
|
||||
{
|
||||
yield return new object[] { 100m, 0.01m, 101m }; // 1% SL
|
||||
yield return new object[] { 100m, 0.05m, 105m }; // 5% SL
|
||||
yield return new object[] { 100m, 0.10m, 110m }; // 10% SL
|
||||
yield return new object[] { 1000m, 0.02m, 1020m }; // Larger price
|
||||
yield return new object[] { 50000m, 0.015m, 50750m }; // Crypto price with 1.5% SL
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetStopLossPriceShortTestData))]
|
||||
public void GetStopLossPrice_WithShortPosition_CalculatesCorrectSLForVariousPercentages(
|
||||
decimal entryPrice, decimal stopLossPercentage, decimal expectedSL)
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Short;
|
||||
var moneyManagement = CreateMoneyManagement(stopLoss: stopLossPercentage);
|
||||
|
||||
// Act
|
||||
var stopLossPrice = RiskHelpers.GetStopLossPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
stopLossPrice.Should().BeApproximately(expectedSL, 0.01m);
|
||||
stopLossPrice.Should().BeGreaterThan(entryPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStopLossPrice_WithZeroPrice_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var entryPrice = 0m;
|
||||
var moneyManagement = CreateMoneyManagement(stopLoss: 0.02m);
|
||||
|
||||
// Act
|
||||
var stopLossPrice = RiskHelpers.GetStopLossPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
stopLossPrice.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStopLossPrice_WithVeryLargeStopLoss_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var entryPrice = 100m;
|
||||
var moneyManagement = CreateMoneyManagement(stopLoss: 0.50m); // 50% SL (extreme but valid)
|
||||
|
||||
// Act
|
||||
var stopLossPrice = RiskHelpers.GetStopLossPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
stopLossPrice.Should().Be(50m); // 100 - (100 * 0.50) = 50
|
||||
stopLossPrice.Should().BeLessThan(entryPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStopLossPrice_WithNegativeStopLoss_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var entryPrice = 100m;
|
||||
var moneyManagement = CreateMoneyManagement(stopLoss: -0.02m); // Negative (edge case)
|
||||
|
||||
// Act
|
||||
var stopLossPrice = RiskHelpers.GetStopLossPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
// With negative SL: 100 - (100 * -0.02) = 100 + 2 = 102 (SL above entry, which is wrong for Long)
|
||||
stopLossPrice.Should().Be(102m);
|
||||
// Note: This is a potential bug - negative SL should probably be handled differently
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetStopLossPrice_WithVerySmallPrice_HandlesPrecision()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var entryPrice = 0.0001m; // Very small price
|
||||
var moneyManagement = CreateMoneyManagement(stopLoss: 0.02m);
|
||||
|
||||
// Act
|
||||
var stopLossPrice = RiskHelpers.GetStopLossPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
stopLossPrice.Should().Be(0.000098m); // 0.0001 - (0.0001 * 0.02) = 0.000098
|
||||
stopLossPrice.Should().BeLessThan(entryPrice);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetTakeProfitPrice Tests
|
||||
|
||||
[Fact]
|
||||
public void GetTakeProfitPrice_WithLongPosition_CalculatesTPAboveEntry()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var entryPrice = 100m;
|
||||
var moneyManagement = CreateMoneyManagement(takeProfit: 0.04m); // 4%
|
||||
|
||||
// Act
|
||||
var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
takeProfitPrice.Should().Be(104m); // 100 + (100 * 0.04) = 104
|
||||
takeProfitPrice.Should().BeGreaterThan(entryPrice, "TP should be above entry for Long position");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTakeProfitPrice_WithShortPosition_CalculatesTPBelowEntry()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Short;
|
||||
var entryPrice = 100m;
|
||||
var moneyManagement = CreateMoneyManagement(takeProfit: 0.04m); // 4%
|
||||
|
||||
// Act
|
||||
var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
takeProfitPrice.Should().Be(96m); // 100 - (100 * 0.04) = 96
|
||||
takeProfitPrice.Should().BeLessThan(entryPrice, "TP should be below entry for Short position");
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> GetTakeProfitPriceLongTestData()
|
||||
{
|
||||
yield return new object[] { 100m, 0.02m, 1, 102m }; // 2% TP, count=1
|
||||
yield return new object[] { 100m, 0.04m, 1, 104m }; // 4% TP, count=1
|
||||
yield return new object[] { 100m, 0.10m, 1, 110m }; // 10% TP, count=1
|
||||
yield return new object[] { 1000m, 0.02m, 1, 1020m }; // Larger price
|
||||
yield return new object[] { 50000m, 0.015m, 1, 50750m }; // Crypto price with 1.5% TP
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetTakeProfitPriceLongTestData))]
|
||||
public void GetTakeProfitPrice_WithLongPosition_CalculatesCorrectTPForVariousPercentages(
|
||||
decimal entryPrice, decimal takeProfitPercentage, int count, decimal expectedTP)
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var moneyManagement = CreateMoneyManagement(takeProfit: takeProfitPercentage);
|
||||
|
||||
// Act
|
||||
var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(direction, entryPrice, moneyManagement, count);
|
||||
|
||||
// Assert
|
||||
takeProfitPrice.Should().BeApproximately(expectedTP, 0.01m);
|
||||
takeProfitPrice.Should().BeGreaterThan(entryPrice);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> GetTakeProfitPriceShortTestData()
|
||||
{
|
||||
yield return new object[] { 100m, 0.02m, 1, 98m }; // 2% TP, count=1
|
||||
yield return new object[] { 100m, 0.04m, 1, 96m }; // 4% TP, count=1
|
||||
yield return new object[] { 100m, 0.10m, 1, 90m }; // 10% TP, count=1
|
||||
yield return new object[] { 1000m, 0.02m, 1, 980m }; // Larger price
|
||||
yield return new object[] { 50000m, 0.015m, 1, 49250m }; // Crypto price with 1.5% TP
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetTakeProfitPriceShortTestData))]
|
||||
public void GetTakeProfitPrice_WithShortPosition_CalculatesCorrectTPForVariousPercentages(
|
||||
decimal entryPrice, decimal takeProfitPercentage, int count, decimal expectedTP)
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Short;
|
||||
var moneyManagement = CreateMoneyManagement(takeProfit: takeProfitPercentage);
|
||||
|
||||
// Act
|
||||
var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(direction, entryPrice, moneyManagement, count);
|
||||
|
||||
// Assert
|
||||
takeProfitPrice.Should().BeApproximately(expectedTP, 0.01m);
|
||||
takeProfitPrice.Should().BeLessThan(entryPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTakeProfitPrice_WithMultipleTPs_CalculatesCumulativePercentage()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var entryPrice = 100m;
|
||||
var moneyManagement = CreateMoneyManagement(takeProfit: 0.04m); // 4% per TP
|
||||
var count = 2; // Second TP
|
||||
|
||||
// Act
|
||||
var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(direction, entryPrice, moneyManagement, count);
|
||||
|
||||
// Assert
|
||||
// TP2 = 100 + (100 * 0.04 * 2) = 100 + 8 = 108
|
||||
takeProfitPrice.Should().Be(108m);
|
||||
takeProfitPrice.Should().BeGreaterThan(entryPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTakeProfitPrice_WithMultipleTPsForShort_CalculatesCumulativePercentage()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Short;
|
||||
var entryPrice = 100m;
|
||||
var moneyManagement = CreateMoneyManagement(takeProfit: 0.04m); // 4% per TP
|
||||
var count = 3; // Third TP
|
||||
|
||||
// Act
|
||||
var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(direction, entryPrice, moneyManagement, count);
|
||||
|
||||
// Assert
|
||||
// TP3 = 100 - (100 * 0.04 * 3) = 100 - 12 = 88
|
||||
takeProfitPrice.Should().Be(88m);
|
||||
takeProfitPrice.Should().BeLessThan(entryPrice);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> GetTakeProfitPriceMultipleCountsTestData()
|
||||
{
|
||||
yield return new object[] { 1, 104m }; // TP1: 100 + (100 * 0.04 * 1) = 104
|
||||
yield return new object[] { 2, 108m }; // TP2: 100 + (100 * 0.04 * 2) = 108
|
||||
yield return new object[] { 3, 112m }; // TP3: 100 + (100 * 0.04 * 3) = 112
|
||||
yield return new object[] { 5, 120m }; // TP5: 100 + (100 * 0.04 * 5) = 120
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetTakeProfitPriceMultipleCountsTestData))]
|
||||
public void GetTakeProfitPrice_WithLongPosition_HandlesMultipleTPCounts(int count, decimal expectedTP)
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var entryPrice = 100m;
|
||||
var moneyManagement = CreateMoneyManagement(takeProfit: 0.04m);
|
||||
|
||||
// Act
|
||||
var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(direction, entryPrice, moneyManagement, count);
|
||||
|
||||
// Assert
|
||||
takeProfitPrice.Should().BeApproximately(expectedTP, 0.01m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTakeProfitPrice_WithZeroPrice_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var entryPrice = 0m;
|
||||
var moneyManagement = CreateMoneyManagement(takeProfit: 0.04m);
|
||||
|
||||
// Act
|
||||
var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
takeProfitPrice.Should().Be(0m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTakeProfitPrice_WithCountZero_ReturnsEntryPrice()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var entryPrice = 100m;
|
||||
var moneyManagement = CreateMoneyManagement(takeProfit: 0.04m);
|
||||
var count = 0; // Edge case: count = 0
|
||||
|
||||
// Act
|
||||
var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(direction, entryPrice, moneyManagement, count);
|
||||
|
||||
// Assert
|
||||
// TP = 100 + (100 * 0.04 * 0) = 100 + 0 = 100
|
||||
takeProfitPrice.Should().Be(entryPrice);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTakeProfitPrice_WithNegativeTakeProfit_HandlesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var entryPrice = 100m;
|
||||
var moneyManagement = CreateMoneyManagement(takeProfit: -0.04m); // Negative (edge case)
|
||||
|
||||
// Act
|
||||
var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
// With negative TP: 100 + (100 * -0.04) = 100 - 4 = 96 (TP below entry, which is wrong for Long)
|
||||
takeProfitPrice.Should().Be(96m);
|
||||
// Note: This is a potential bug - negative TP should probably be handled differently
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetTakeProfitPrice_WithVerySmallPrice_HandlesPrecision()
|
||||
{
|
||||
// Arrange
|
||||
var direction = TradeDirection.Long;
|
||||
var entryPrice = 0.0001m; // Very small price
|
||||
var moneyManagement = CreateMoneyManagement(takeProfit: 0.04m);
|
||||
|
||||
// Act
|
||||
var takeProfitPrice = RiskHelpers.GetTakeProfitPrice(direction, entryPrice, moneyManagement);
|
||||
|
||||
// Assert
|
||||
takeProfitPrice.Should().Be(0.000104m); // 0.0001 + (0.0001 * 0.04) = 0.000104
|
||||
takeProfitPrice.Should().BeGreaterThan(entryPrice);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetRiskFromConfidence Tests
|
||||
|
||||
[Fact]
|
||||
public void GetRiskFromConfidence_WithLowConfidence_ReturnsLowRisk()
|
||||
{
|
||||
// Arrange
|
||||
var confidence = Confidence.Low;
|
||||
|
||||
// Act
|
||||
var riskLevel = RiskHelpers.GetRiskFromConfidence(confidence);
|
||||
|
||||
// Assert
|
||||
riskLevel.Should().Be(RiskLevel.Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRiskFromConfidence_WithMediumConfidence_ReturnsMediumRisk()
|
||||
{
|
||||
// Arrange
|
||||
var confidence = Confidence.Medium;
|
||||
|
||||
// Act
|
||||
var riskLevel = RiskHelpers.GetRiskFromConfidence(confidence);
|
||||
|
||||
// Assert
|
||||
riskLevel.Should().Be(RiskLevel.Medium);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRiskFromConfidence_WithHighConfidence_ReturnsHighRisk()
|
||||
{
|
||||
// Arrange
|
||||
var confidence = Confidence.High;
|
||||
|
||||
// Act
|
||||
var riskLevel = RiskHelpers.GetRiskFromConfidence(confidence);
|
||||
|
||||
// Assert
|
||||
riskLevel.Should().Be(RiskLevel.High);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRiskFromConfidence_WithNoneConfidence_ReturnsLowRiskAsDefault()
|
||||
{
|
||||
// Arrange
|
||||
var confidence = Confidence.None;
|
||||
|
||||
// Act
|
||||
var riskLevel = RiskHelpers.GetRiskFromConfidence(confidence);
|
||||
|
||||
// Assert
|
||||
riskLevel.Should().Be(RiskLevel.Low, "None confidence should default to Low risk for safety");
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> GetRiskFromConfidenceTestData()
|
||||
{
|
||||
yield return new object[] { Confidence.Low, RiskLevel.Low };
|
||||
yield return new object[] { Confidence.Medium, RiskLevel.Medium };
|
||||
yield return new object[] { Confidence.High, RiskLevel.High };
|
||||
yield return new object[] { Confidence.None, RiskLevel.Low };
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GetRiskFromConfidenceTestData))]
|
||||
public void GetRiskFromConfidence_WithAllConfidenceValues_MapsCorrectly(Confidence confidence, RiskLevel expectedRisk)
|
||||
{
|
||||
// Act
|
||||
var riskLevel = RiskHelpers.GetRiskFromConfidence(confidence);
|
||||
|
||||
// Assert
|
||||
riskLevel.Should().Be(expectedRisk);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
737
src/Managing.Domain.Tests/TradingBotCalculationsTests.cs
Normal file
737
src/Managing.Domain.Tests/TradingBotCalculationsTests.cs
Normal file
@@ -0,0 +1,737 @@
|
||||
using FluentAssertions;
|
||||
using Managing.Domain.Shared.Helpers;
|
||||
using Managing.Domain.Trades;
|
||||
using Xunit;
|
||||
using static Managing.Common.Enums;
|
||||
|
||||
namespace Managing.Domain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for TradingBot calculation methods extracted from TradingBotBase.
|
||||
/// These methods handle core trading calculations: PnL, position sizing, profit checks, cooldown, time limits, and loss streaks.
|
||||
/// </summary>
|
||||
public class TradingBotCalculationsTests : TradingBoxTests
|
||||
{
|
||||
#region CalculatePositionSize Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(1.0, 1.0, 1.0)]
|
||||
[InlineData(2.5, 3.0, 7.5)]
|
||||
[InlineData(0.1, 10.0, 1.0)]
|
||||
[InlineData(100.0, 5.0, 500.0)]
|
||||
[InlineData(0.001, 20.0, 0.02)]
|
||||
public void CalculatePositionSize_WithValidInputs_ReturnsCorrectSize(decimal quantity, decimal leverage, decimal expected)
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.CalculatePositionSize(quantity, leverage);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePositionSize_WithZeroQuantity_ReturnsZero()
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.CalculatePositionSize(0, 10);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePositionSize_WithZeroLeverage_ReturnsZero()
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.CalculatePositionSize(100, 0);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CalculatePnL Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(100.0, 110.0, 1.0, 1.0, TradeDirection.Long, 10.0)] // Long: (110-100) * 1 * 1 = 10
|
||||
[InlineData(100.0, 90.0, 1.0, 1.0, TradeDirection.Long, -10.0)] // Long: (90-100) * 1 * 1 = -10
|
||||
[InlineData(100.0, 110.0, 1.0, 1.0, TradeDirection.Short, -10.0)] // Short: (100-110) * 1 * 1 = -10
|
||||
[InlineData(100.0, 90.0, 1.0, 1.0, TradeDirection.Short, 10.0)] // Short: (100-90) * 1 * 1 = 10
|
||||
[InlineData(100.0, 110.0, 2.0, 5.0, TradeDirection.Long, 100.0)] // Long: (110-100) * 2 * 5 = 100
|
||||
[InlineData(100.0, 90.0, 2.0, 5.0, TradeDirection.Short, 100.0)] // Short: (100-90) * 2 * 5 = 100
|
||||
public void CalculatePnL_WithValidInputs_ReturnsCorrectPnL(
|
||||
decimal entryPrice, decimal exitPrice, decimal quantity, decimal leverage,
|
||||
TradeDirection direction, decimal expectedPnL)
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.CalculatePnL(entryPrice, exitPrice, quantity, leverage, direction);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expectedPnL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePnL_LongPosition_Profitable_ReturnsPositive()
|
||||
{
|
||||
// Arrange
|
||||
var entryPrice = 100m;
|
||||
var exitPrice = 105m;
|
||||
var quantity = 1m;
|
||||
var leverage = 1m;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculatePnL(entryPrice, exitPrice, quantity, leverage, TradeDirection.Long);
|
||||
|
||||
// Assert
|
||||
result.Should().BePositive();
|
||||
result.Should().Be(5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePnL_LongPosition_Loss_ReturnsNegative()
|
||||
{
|
||||
// Arrange
|
||||
var entryPrice = 100m;
|
||||
var exitPrice = 95m;
|
||||
var quantity = 1m;
|
||||
var leverage = 1m;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculatePnL(entryPrice, exitPrice, quantity, leverage, TradeDirection.Long);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNegative();
|
||||
result.Should().Be(-5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePnL_ShortPosition_Profitable_ReturnsPositive()
|
||||
{
|
||||
// Arrange
|
||||
var entryPrice = 100m;
|
||||
var exitPrice = 95m;
|
||||
var quantity = 1m;
|
||||
var leverage = 1m;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculatePnL(entryPrice, exitPrice, quantity, leverage, TradeDirection.Short);
|
||||
|
||||
// Assert
|
||||
result.Should().BePositive();
|
||||
result.Should().Be(5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePnL_ShortPosition_Loss_ReturnsNegative()
|
||||
{
|
||||
// Arrange
|
||||
var entryPrice = 100m;
|
||||
var exitPrice = 105m;
|
||||
var quantity = 1m;
|
||||
var leverage = 1m;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculatePnL(entryPrice, exitPrice, quantity, leverage, TradeDirection.Short);
|
||||
|
||||
// Assert
|
||||
result.Should().BeNegative();
|
||||
result.Should().Be(-5m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePnL_WithLeverage_MultipliesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var entryPrice = 100m;
|
||||
var exitPrice = 110m;
|
||||
var quantity = 1m;
|
||||
var leverage = 5m;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculatePnL(entryPrice, exitPrice, quantity, leverage, TradeDirection.Long);
|
||||
|
||||
// Assert
|
||||
// (110-100) * 1 * 5 = 50
|
||||
result.Should().Be(50m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePnL_SameEntryAndExit_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var price = 100m;
|
||||
var quantity = 1m;
|
||||
var leverage = 1m;
|
||||
|
||||
// Act
|
||||
var longResult = TradingBox.CalculatePnL(price, price, quantity, leverage, TradeDirection.Long);
|
||||
var shortResult = TradingBox.CalculatePnL(price, price, quantity, leverage, TradeDirection.Short);
|
||||
|
||||
// Assert
|
||||
longResult.Should().Be(0);
|
||||
shortResult.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CalculatePriceDifference Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(100.0, 110.0, TradeDirection.Long, 10.0)] // Long: 110-100 = 10
|
||||
[InlineData(100.0, 90.0, TradeDirection.Long, -10.0)] // Long: 90-100 = -10
|
||||
[InlineData(100.0, 110.0, TradeDirection.Short, -10.0)] // Short: 100-110 = -10
|
||||
[InlineData(100.0, 90.0, TradeDirection.Short, 10.0)] // Short: 100-90 = 10
|
||||
public void CalculatePriceDifference_WithValidInputs_ReturnsCorrectDifference(
|
||||
decimal entryPrice, decimal exitPrice, TradeDirection direction, decimal expected)
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.CalculatePriceDifference(entryPrice, exitPrice, direction);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePriceDifference_SamePrices_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var price = 100m;
|
||||
|
||||
// Act
|
||||
var longResult = TradingBox.CalculatePriceDifference(price, price, TradeDirection.Long);
|
||||
var shortResult = TradingBox.CalculatePriceDifference(price, price, TradeDirection.Short);
|
||||
|
||||
// Assert
|
||||
longResult.Should().Be(0);
|
||||
shortResult.Should().Be(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CalculatePnLPercentage Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(10.0, 100.0, 1.0, 10.0)] // 10 / (100 * 1) * 100 = 10%
|
||||
[InlineData(5.0, 100.0, 1.0, 5.0)] // 5 / (100 * 1) * 100 = 5%
|
||||
[InlineData(-10.0, 100.0, 1.0, -10.0)] // -10 / (100 * 1) * 100 = -10%
|
||||
[InlineData(20.0, 100.0, 2.0, 10.0)] // 20 / (100 * 2) * 100 = 10%
|
||||
[InlineData(0.0, 100.0, 1.0, 0.0)] // 0 / (100 * 1) * 100 = 0%
|
||||
public void CalculatePnLPercentage_WithValidInputs_ReturnsCorrectPercentage(
|
||||
decimal pnl, decimal entryPrice, decimal quantity, decimal expectedPercentage)
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.CalculatePnLPercentage(pnl, entryPrice, quantity);
|
||||
|
||||
// Assert
|
||||
result.Should().BeApproximately(expectedPercentage, 0.01m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePnLPercentage_WithZeroDenominator_ReturnsZero()
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.CalculatePnLPercentage(10, 0, 1);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePnLPercentage_WithZeroQuantity_ReturnsZero()
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.CalculatePnLPercentage(10, 100, 0);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculatePnLPercentage_RoundsToTwoDecimals()
|
||||
{
|
||||
// Arrange
|
||||
var pnl = 3.333333m;
|
||||
var entryPrice = 100m;
|
||||
var quantity = 1m;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculatePnLPercentage(pnl, entryPrice, quantity);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(3.33m); // Rounded to 2 decimals
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsPositionInProfit Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(100.0, 110.0, TradeDirection.Long, true)] // Long: 110 > 100 = profit
|
||||
[InlineData(100.0, 90.0, TradeDirection.Long, false)] // Long: 90 < 100 = loss
|
||||
[InlineData(100.0, 100.0, TradeDirection.Long, false)] // Long: 100 == 100 = no profit
|
||||
[InlineData(100.0, 90.0, TradeDirection.Short, true)] // Short: 90 < 100 = profit
|
||||
[InlineData(100.0, 110.0, TradeDirection.Short, false)] // Short: 110 > 100 = loss
|
||||
[InlineData(100.0, 100.0, TradeDirection.Short, false)] // Short: 100 == 100 = no profit
|
||||
public void IsPositionInProfit_WithValidInputs_ReturnsCorrectResult(
|
||||
decimal entryPrice, decimal currentPrice, TradeDirection direction, bool expected)
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.IsPositionInProfit(entryPrice, currentPrice, direction);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPositionInProfit_LongPosition_ExactlyAtEntry_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.IsPositionInProfit(100, 100, TradeDirection.Long);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPositionInProfit_ShortPosition_ExactlyAtEntry_ReturnsFalse()
|
||||
{
|
||||
// Act
|
||||
var result = TradingBox.IsPositionInProfit(100, 100, TradeDirection.Short);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CalculateCooldownEndTime Tests
|
||||
|
||||
[Fact]
|
||||
public void CalculateCooldownEndTime_WithFiveMinutesTimeframe_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var lastClosingTime = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var timeframe = Timeframe.FiveMinutes;
|
||||
var cooldownPeriod = 2; // 2 candles
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculateCooldownEndTime(lastClosingTime, timeframe, cooldownPeriod);
|
||||
|
||||
// Assert
|
||||
// 5 minutes = 300 seconds, 2 candles = 600 seconds = 10 minutes
|
||||
result.Should().Be(lastClosingTime.AddSeconds(600));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateCooldownEndTime_WithFifteenMinutesTimeframe_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var lastClosingTime = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var timeframe = Timeframe.FifteenMinutes;
|
||||
var cooldownPeriod = 1; // 1 candle
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculateCooldownEndTime(lastClosingTime, timeframe, cooldownPeriod);
|
||||
|
||||
// Assert
|
||||
// 15 minutes = 900 seconds, 1 candle = 900 seconds = 15 minutes
|
||||
result.Should().Be(lastClosingTime.AddSeconds(900));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateCooldownEndTime_WithOneHourTimeframe_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var lastClosingTime = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var timeframe = Timeframe.OneHour;
|
||||
var cooldownPeriod = 3; // 3 candles
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculateCooldownEndTime(lastClosingTime, timeframe, cooldownPeriod);
|
||||
|
||||
// Assert
|
||||
// 1 hour = 3600 seconds, 3 candles = 10800 seconds = 3 hours
|
||||
result.Should().Be(lastClosingTime.AddSeconds(10800));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateCooldownEndTime_WithZeroCooldown_ReturnsSameTime()
|
||||
{
|
||||
// Arrange
|
||||
var lastClosingTime = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var timeframe = Timeframe.FifteenMinutes;
|
||||
var cooldownPeriod = 0;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculateCooldownEndTime(lastClosingTime, timeframe, cooldownPeriod);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(lastClosingTime);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Timeframe.FiveMinutes, 300)]
|
||||
[InlineData(Timeframe.FifteenMinutes, 900)]
|
||||
[InlineData(Timeframe.ThirtyMinutes, 1800)]
|
||||
[InlineData(Timeframe.OneHour, 3600)]
|
||||
[InlineData(Timeframe.FourHour, 14400)]
|
||||
[InlineData(Timeframe.OneDay, 86400)]
|
||||
public void CalculateCooldownEndTime_WithDifferentTimeframes_UsesCorrectIntervals(Timeframe timeframe, int expectedSeconds)
|
||||
{
|
||||
// Arrange
|
||||
var lastClosingTime = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var cooldownPeriod = 1;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CalculateCooldownEndTime(lastClosingTime, timeframe, cooldownPeriod);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(lastClosingTime.AddSeconds(expectedSeconds));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HasPositionExceededTimeLimit Tests
|
||||
|
||||
[Fact]
|
||||
public void HasPositionExceededTimeLimit_WhenTimeExceeded_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var openDate = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var currentTime = new DateTime(2024, 1, 1, 15, 0, 0, DateTimeKind.Utc); // 3 hours later
|
||||
var maxHours = 2m; // Max 2 hours
|
||||
|
||||
// Act
|
||||
var result = TradingBox.HasPositionExceededTimeLimit(openDate, currentTime, maxHours);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPositionExceededTimeLimit_WhenTimeNotExceeded_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var openDate = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var currentTime = new DateTime(2024, 1, 1, 13, 30, 0, DateTimeKind.Utc); // 1.5 hours later
|
||||
var maxHours = 2m; // Max 2 hours
|
||||
|
||||
// Act
|
||||
var result = TradingBox.HasPositionExceededTimeLimit(openDate, currentTime, maxHours);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPositionExceededTimeLimit_WhenExactlyAtLimit_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var openDate = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var currentTime = new DateTime(2024, 1, 1, 14, 0, 0, DateTimeKind.Utc); // Exactly 2 hours later
|
||||
var maxHours = 2m; // Max 2 hours
|
||||
|
||||
// Act
|
||||
var result = TradingBox.HasPositionExceededTimeLimit(openDate, currentTime, maxHours);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue(); // >= means it's exceeded
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPositionExceededTimeLimit_WithNullMaxHours_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var openDate = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var currentTime = new DateTime(2024, 1, 1, 20, 0, 0, DateTimeKind.Utc); // 8 hours later
|
||||
decimal? maxHours = null;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.HasPositionExceededTimeLimit(openDate, currentTime, maxHours);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse(); // No limit when null
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPositionExceededTimeLimit_WithZeroMaxHours_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var openDate = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var currentTime = new DateTime(2024, 1, 1, 20, 0, 0, DateTimeKind.Utc); // 8 hours later
|
||||
decimal? maxHours = 0m;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.HasPositionExceededTimeLimit(openDate, currentTime, maxHours);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse(); // No limit when 0
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPositionExceededTimeLimit_WithNegativeMaxHours_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var openDate = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var currentTime = new DateTime(2024, 1, 1, 20, 0, 0, DateTimeKind.Utc);
|
||||
decimal? maxHours = -5m;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.HasPositionExceededTimeLimit(openDate, currentTime, maxHours);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse(); // No limit when negative
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPositionExceededTimeLimit_WithDecimalHours_CalculatesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var openDate = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||
var currentTime = new DateTime(2024, 1, 1, 12, 45, 0, DateTimeKind.Utc); // 0.75 hours later
|
||||
var maxHours = 0.5m; // Max 0.5 hours (30 minutes)
|
||||
|
||||
// Act
|
||||
var result = TradingBox.HasPositionExceededTimeLimit(openDate, currentTime, maxHours);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue(); // 0.75 > 0.5
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region CheckLossStreak Tests
|
||||
|
||||
[Fact]
|
||||
public void CheckLossStreak_WithZeroMaxLossStreak_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var recentPositions = CreateLossPositions(3);
|
||||
var maxLossStreak = 0;
|
||||
var signalDirection = TradeDirection.Long;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CheckLossStreak(recentPositions, maxLossStreak, signalDirection);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue(); // No limit when 0
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLossStreak_WithNegativeMaxLossStreak_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var recentPositions = CreateLossPositions(3);
|
||||
var maxLossStreak = -5;
|
||||
var signalDirection = TradeDirection.Long;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CheckLossStreak(recentPositions, maxLossStreak, signalDirection);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue(); // No limit when negative
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLossStreak_WithNotEnoughPositions_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var recentPositions = CreateLossPositions(2); // Only 2 positions
|
||||
var maxLossStreak = 3; // Need 3 for streak
|
||||
var signalDirection = TradeDirection.Long;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CheckLossStreak(recentPositions, maxLossStreak, signalDirection);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue(); // Not enough positions to form streak
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLossStreak_WithNotAllLosses_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var recentPositions = CreateMixedPositions(3); // Mix of wins and losses
|
||||
var maxLossStreak = 3;
|
||||
var signalDirection = TradeDirection.Long;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CheckLossStreak(recentPositions, maxLossStreak, signalDirection);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue(); // Not all losses, so no block
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLossStreak_WithAllLossesSameDirection_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var recentPositions = CreateLossPositions(3, TradeDirection.Long);
|
||||
var maxLossStreak = 3;
|
||||
var signalDirection = TradeDirection.Long; // Same direction as losses
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CheckLossStreak(recentPositions, maxLossStreak, signalDirection);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse(); // Block same direction after loss streak
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLossStreak_WithAllLossesOppositeDirection_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var recentPositions = CreateLossPositions(3, TradeDirection.Long);
|
||||
var maxLossStreak = 3;
|
||||
var signalDirection = TradeDirection.Short; // Opposite direction
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CheckLossStreak(recentPositions, maxLossStreak, signalDirection);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue(); // Allow opposite direction
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLossStreak_WithEmptyPositionsList_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var recentPositions = new List<Position>();
|
||||
var maxLossStreak = 3;
|
||||
var signalDirection = TradeDirection.Long;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CheckLossStreak(recentPositions, maxLossStreak, signalDirection);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue(); // No positions, can open
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLossStreak_WithPositionsWithoutProfitAndLoss_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var recentPositions = CreatePositionsWithoutPnL(3);
|
||||
var maxLossStreak = 3;
|
||||
var signalDirection = TradeDirection.Long;
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CheckLossStreak(recentPositions, maxLossStreak, signalDirection);
|
||||
|
||||
// Assert
|
||||
result.Should().BeTrue(); // No PnL data, can't determine if losses
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLossStreak_WithExactlyMaxLossStreak_BlocksSameDirection()
|
||||
{
|
||||
// Arrange
|
||||
var recentPositions = CreateLossPositions(5, TradeDirection.Short);
|
||||
var maxLossStreak = 5;
|
||||
var signalDirection = TradeDirection.Short; // Same direction
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CheckLossStreak(recentPositions, maxLossStreak, signalDirection);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse(); // Block when exactly at limit
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CheckLossStreak_WithMoreThanMaxLossStreak_BlocksSameDirection()
|
||||
{
|
||||
// Arrange
|
||||
var recentPositions = CreateLossPositions(10, TradeDirection.Long);
|
||||
var maxLossStreak = 5;
|
||||
var signalDirection = TradeDirection.Long; // Same direction
|
||||
|
||||
// Act
|
||||
var result = TradingBox.CheckLossStreak(recentPositions, maxLossStreak, signalDirection);
|
||||
|
||||
// Assert
|
||||
result.Should().BeFalse(); // Block when more than limit
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private List<Position> CreateLossPositions(int count, TradeDirection direction = TradeDirection.Long)
|
||||
{
|
||||
var positions = new List<Position>();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var position = CreateFinishedPosition(
|
||||
openPrice: 100m,
|
||||
quantity: 1m,
|
||||
direction: direction
|
||||
);
|
||||
|
||||
// Set as loss
|
||||
position.ProfitAndLoss = new ProfitAndLoss
|
||||
{
|
||||
Realized = -10m - (i * 5m), // Loss
|
||||
Net = -10m - (i * 5m)
|
||||
};
|
||||
position.Open.Date = DateTime.UtcNow.AddHours(-i);
|
||||
|
||||
positions.Add(position);
|
||||
}
|
||||
|
||||
return positions.OrderByDescending(p => p.Open.Date).ToList();
|
||||
}
|
||||
|
||||
private List<Position> CreateMixedPositions(int count)
|
||||
{
|
||||
var positions = new List<Position>();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var direction = i % 2 == 0 ? TradeDirection.Long : TradeDirection.Short;
|
||||
var position = CreateFinishedPosition(
|
||||
openPrice: 100m,
|
||||
quantity: 1m,
|
||||
direction: direction
|
||||
);
|
||||
|
||||
// Alternate between win and loss
|
||||
position.ProfitAndLoss = new ProfitAndLoss
|
||||
{
|
||||
Realized = i % 2 == 0 ? 10m : -10m,
|
||||
Net = i % 2 == 0 ? 10m : -10m
|
||||
};
|
||||
position.Open.Date = DateTime.UtcNow.AddHours(-i);
|
||||
|
||||
positions.Add(position);
|
||||
}
|
||||
|
||||
return positions.OrderByDescending(p => p.Open.Date).ToList();
|
||||
}
|
||||
|
||||
private List<Position> CreatePositionsWithoutPnL(int count)
|
||||
{
|
||||
var positions = new List<Position>();
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var position = CreateFinishedPosition(
|
||||
openPrice: 100m,
|
||||
quantity: 1m,
|
||||
direction: TradeDirection.Long
|
||||
);
|
||||
|
||||
position.Status = PositionStatus.Finished;
|
||||
position.ProfitAndLoss = null; // No ProfitAndLoss set
|
||||
position.Open.Date = DateTime.UtcNow.AddHours(-i);
|
||||
|
||||
positions.Add(position);
|
||||
}
|
||||
|
||||
return positions.OrderByDescending(p => p.Open.Date).ToList();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user