using Managing.Domain.Backtests; using static Managing.Common.Enums; public class BacktestScorer { // Updated weights with more emphasis on HODL comparison and risk management private static readonly Dictionary Weights = new Dictionary { { "GrowthPercentage", 0.20 }, { "SharpeRatio", 0.15 }, { "MaxDrawdownUsd", 0.10 }, { "HodlComparison", 0.15 }, // Increased from 0.05 to 0.15 { "WinRate", 0.12 }, { "ProfitabilityBonus", 0.08 }, { "TradeCount", 0.05 }, { "RecoveryTime", 0.02 }, { "TestDuration", 0.03 }, { "FeesImpact", 0.02 }, { "RiskAdjustedReturn", 0.08 } // New component }; public static double CalculateTotalScore(BacktestScoringParams p) { try { // Early exit for no positions if (p.TradeCount == 0) { return 0; } var baseScore = CalculateBaseScore(p); var finalScore = ApplyProfitabilityRules(baseScore, p); return double.Clamp(finalScore, 0, 100); } catch { return 0; } } private static double CalculateBaseScore(BacktestScoringParams p) { var componentScores = new Dictionary { { "GrowthPercentage", CalculateGrowthScore(p.GrowthPercentage) }, { "SharpeRatio", CalculateSharpeScore(p.SharpeRatio) }, { "MaxDrawdownUsd", CalculateDrawdownUsdScore(p.MaxDrawdown, p.InitialBalance) }, { "HodlComparison", CalculateHodlComparisonScore(p.GrowthPercentage, p.HodlPercentage) }, { "WinRate", CalculateWinRateScore(p.WinRate, p.TradeCount) }, { "ProfitabilityBonus", CalculateProfitabilityBonus(p.GrowthPercentage) }, { "TradeCount", CalculateTradeCountScore(p.TradeCount) }, { "RecoveryTime", CalculateRecoveryScore(p.MaxDrawdownRecoveryTime, p.Timeframe) }, { "TestDuration", CalculateTestDurationScore(p.StartDate, p.EndDate, p.Timeframe) }, { "FeesImpact", CalculateFeesImpactScore(p.FeesPaid, p.InitialBalance, (decimal)p.TotalPnL) }, { "RiskAdjustedReturn", CalculateRiskAdjustedReturnScore(p.TotalPnL, p.MaxDrawdown, p.InitialBalance) } }; return componentScores.Sum(kvp => kvp.Value * Weights[kvp.Key]); } private static double ApplyProfitabilityRules(double baseScore, BacktestScoringParams p) { var penaltyMultiplier = 1.0; // 1. Negative PnL Penalty (Dynamic) if (p.GrowthPercentage < 0) { var negativePenalty = Math.Abs(p.GrowthPercentage) * 0.1; // 10% penalty per 1% loss penaltyMultiplier *= Math.Max(0.1, 1 - negativePenalty); } // 2. Absolute PnL Validation (Dynamic) if (p.TotalPnL <= 0) { penaltyMultiplier *= 0.3; // 70% penalty for negative absolute PnL } // 3. Win Rate Validation (Dynamic) if (p.WinRate < 0.3 && p.TradeCount > 10) { var winRatePenalty = (0.3 - p.WinRate) * 0.5; // 50% penalty per 10% below 30% penaltyMultiplier *= Math.Max(0.2, 1 - winRatePenalty); } // 4. Minimum Profit Threshold (Dynamic) if (p.GrowthPercentage < 2 && p.TradeCount > 5) { var profitPenalty = (2 - p.GrowthPercentage) * 0.1; // 10% penalty per 1% below 2% penaltyMultiplier *= Math.Max(0.5, 1 - profitPenalty); } // 5. Drawdown Penalty (Dynamic) - Enhanced to consider PnL ratio if (p.MaxDrawdownPc > 20) { var drawdownPenalty = (p.MaxDrawdownPc - 20) * 0.02; // 2% penalty per 1% above 20% penaltyMultiplier *= Math.Max(0.3, 1 - drawdownPenalty); } // 6. Enhanced Drawdown vs PnL Penalty if (p.TotalPnL > 0 && p.MaxDrawdown > 0) { var drawdownToPnLRatio = (double)(p.MaxDrawdown / (decimal)p.TotalPnL); if (drawdownToPnLRatio > 1.5) // If drawdown is more than 150% of PnL { var ratioPenalty = (drawdownToPnLRatio - 1.5) * 0.2; // 20% penalty per 0.5 ratio above 1.5 penaltyMultiplier *= Math.Max(0.2, 1 - ratioPenalty); } } // 7. Test Duration Penalty (Dynamic) var testDurationDays = (p.EndDate - p.StartDate).TotalDays; if (testDurationDays < 30) { var durationPenalty = (30 - testDurationDays) * 0.02; // 2% penalty per day below 30 penaltyMultiplier *= Math.Max(0.5, 1 - durationPenalty); } return baseScore * penaltyMultiplier; } private static double CalculateGrowthScore(double growthPercentage) { // More aggressive scoring - harder to reach 100 if (growthPercentage < 0) { return Math.Max(0, 20 + (growthPercentage * 1.5)); // -10% → 5, -20% → 0 } // Require minimum 10% growth for full score (increased from 5%) return growthPercentage switch { < 5 => growthPercentage * 8, // 2% → 16, 4% → 32 < 10 => 40 + (growthPercentage - 5) * 12, // 5% → 40, 7% → 64, 9% → 88 _ => 100 }; } private static double GetNegativePnLMultiplier(double growthPercentage) { return growthPercentage switch { > -5 => 0.6, > -10 => 0.4, > -20 => 0.2, _ => 0.1 }; } private static double CalculateProfitabilityBonus(double growthPercentage) { return growthPercentage switch { > 0 => 50 * (1 - 1 / (1 + growthPercentage / 30)), // Reduced max bonus to 50 _ => 0 }; } private static double CalculateSharpeScore(double sharpeRatio) { return sharpeRatio switch { < 0 => 0, > 4 => 100, // Increased threshold from 3 to 4 _ => (sharpeRatio / 4) * 100 }; } private static double CalculateDrawdownUsdScore(decimal maxDrawdown, decimal initialBalance) { if (initialBalance <= 0) return 0; var drawdownPercentage = (double)(maxDrawdown / initialBalance * 100); return drawdownPercentage switch { > 30 => 0, // 30% drawdown in USD = 0 score _ => 100 - Math.Pow(drawdownPercentage / 30 * 100, 1.5) / 100 }; } private static double CalculateWinRateScore(double winRate, int tradeCount) { // Base win rate score var baseScore = winRate * 100; // Significance factor - more aggressive var significanceFactor = Math.Min(1, (tradeCount - 5) / 50.0); // Start at 5 trades, full significance at 55 trades // Additional penalty for very few trades if (tradeCount < 10) { significanceFactor *= 0.5; // 50% penalty for less than 10 trades } return baseScore * significanceFactor; } private static double CalculateHodlComparisonScore(double strategyGrowth, double hodlGrowth) { var difference = strategyGrowth - hodlGrowth; // Much more aggressive scoring for HODL comparison return difference switch { > 5 => 100, // Significantly outperform HODL (>5% better) > 2 => 80 + (difference - 2) * 6.67, // 2-5% better: 80-100 points > 0 => 40 + difference * 20, // 0-2% better: 40-80 points > -2 => 20 + (difference + 2) * 10, // -2-0%: 20-40 points > -5 => Math.Max(0, 10 + (difference + 5) * 3.33), // -5 to -2%: 0-20 points _ => 0 // More than 5% worse than HODL = 0 points }; } private static double CalculateTradeCountScore(int tradeCount) { return tradeCount switch { < 5 => 0, < 10 => (tradeCount - 5) * 10, // 5-10 trades: 0-50 points < 50 => 50 + (tradeCount - 10) * 1.25, // 10-50 trades: 50-100 points _ => 100 }; } private static double CalculateRecoveryScore(TimeSpan recoveryTime, Timeframe timeframe) { var days = recoveryTime.TotalDays; // Adjust recovery expectations based on timeframe var maxRecoveryDays = timeframe switch { Timeframe.FiveMinutes => 3.0, // 1 week for 5m Timeframe.FifteenMinutes => 5.0, // 2 weeks for 15m Timeframe.ThirtyMinutes => 10.0, // 3 weeks for 30m Timeframe.OneHour => 15.0, // 1 month for 1h Timeframe.FourHour => 30.0, // 2 months for 4h Timeframe.OneDay => 90.0, // 6 months for 1d _ => 30.0 // Default to 1 month }; if (days < 0) return 100; if (days > maxRecoveryDays) return 0; return 100 - (days / maxRecoveryDays * 100); } private static double CalculateTestDurationScore(DateTime startDate, DateTime endDate, Timeframe timeframe) { var durationDays = (endDate - startDate).TotalDays; // Adjust minimum test duration based on timeframe var minTestDays = timeframe switch { Timeframe.FiveMinutes => 14.0, // 3 days for 5m Timeframe.FifteenMinutes => 28.0, // 1 week for 15m Timeframe.ThirtyMinutes => 56.0, // 2 weeks for 30m Timeframe.OneHour => 84.0, // 3 weeks for 1h Timeframe.FourHour => 120.0, // 1 month for 4h Timeframe.OneDay => 90.0, // 3 months for 1d _ => 21.0 // Default to 3 weeks }; var optimalTestDays = minTestDays * 3; // Optimal is 3x minimum if (durationDays < minTestDays) return 0; if (durationDays < optimalTestDays) return (durationDays / optimalTestDays) * 100; return 100; } private static double CalculateFeesImpactScore(decimal feesPaid, decimal initialBalance, decimal totalPnL) { if (initialBalance <= 0) return 0; var feesPercentage = (double)(feesPaid / initialBalance * 100); var pnlPercentage = (double)(totalPnL / initialBalance * 100); // If fees are higher than PnL, heavy penalty if (feesPaid > totalPnL && totalPnL > 0) { return 0; } // Fee efficiency score var feeEfficiency = feesPercentage switch { > 5 => 0, // More than 5% fees = 0 > 2 => 50 - (feesPercentage - 2) * 16.67, // 2-5%: 50-0 points _ => 100 - feesPercentage * 25 // 0-2%: 100-50 points }; return feeEfficiency; } private static double CalculateRiskAdjustedReturnScore(double totalPnL, decimal maxDrawdown, decimal initialBalance) { if (initialBalance <= 0 || maxDrawdown <= 0) return 0; var pnlRatio = totalPnL / (double)maxDrawdown; // Score based on PnL to drawdown ratio return pnlRatio switch { > 3 => 100, // Excellent risk-adjusted return (>3:1) > 2 => 80 + (pnlRatio - 2) * 20, // 2-3:1: 80-100 points > 1.5 => 60 + (pnlRatio - 1.5) * 40, // 1.5-2:1: 60-80 points > 1 => 40 + (pnlRatio - 1) * 40, // 1-1.5:1: 40-60 points > 0.5 => 20 + (pnlRatio - 0.5) * 40, // 0.5-1:1: 20-40 points _ => Math.Max(0, pnlRatio * 40) // 0-0.5:1: 0-20 points }; } }