Add scoring message

This commit is contained in:
2025-07-17 15:57:51 +07:00
parent da48ee37ba
commit e27b4c4a76
9 changed files with 224 additions and 46 deletions

View File

@@ -16,4 +16,5 @@ public class LightBacktestResponse
public decimal Fees { get; set; }
public double? SharpeRatio { get; set; }
public double Score { get; set; }
public string ScoreMessage { get; set; } = string.Empty;
}

View File

@@ -317,7 +317,7 @@ namespace Managing.Application.Backtesting
timeframe: config.Timeframe
);
var score = BacktestScorer.CalculateTotalScore(scoringParams);
var scoringResult = BacktestScorer.CalculateDetailedScore(scoringParams);
// Create backtest result with conditional candles and indicators values
var result = new Backtest(config, bot.Positions, bot.Signals.ToList(),
@@ -334,7 +334,8 @@ namespace Managing.Application.Backtesting
IndicatorsValues = withCandles
? AggregateValues(indicatorsValues, bot.IndicatorsValues)
: new Dictionary<IndicatorType, IndicatorsResultBase>(),
Score = score,
Score = scoringResult.Score,
ScoreMessage = scoringResult.SummaryMessage,
Id = Guid.NewGuid().ToString(),
RequestId = requestId,
Metadata = metadata,

View File

@@ -60,6 +60,7 @@ public class Backtest
[Required] public double Score { get; set; }
public string RequestId { get; set; }
public object? Metadata { get; set; }
public string ScoreMessage { get; set; } = string.Empty;
/// <summary>
/// Creates a new TradingBotConfig based on this backtest's configuration for starting a live bot.

View File

@@ -0,0 +1,114 @@
using System.Text;
namespace Managing.Domain.Backtests;
public class BacktestScoringResult
{
public double Score { get; set; }
public List<ScoringCheck> Checks { get; set; } = new();
public string SummaryMessage { get; set; } = string.Empty;
public BacktestScoringResult(double score)
{
Score = score;
}
public void AddCheck(string component, double score, double weight, string message, bool passed = true)
{
Checks.Add(new ScoringCheck
{
Component = component,
Score = score,
Weight = weight,
Message = message,
Passed = passed
});
}
public void AddEarlyExitCheck(string reason, string message)
{
Checks.Add(new ScoringCheck
{
Component = "Early Exit",
Score = 0,
Weight = 0,
Message = message,
Passed = false,
IsEarlyExit = true
});
}
public void AddPenaltyCheck(string component, double penaltyMultiplier, string message)
{
Checks.Add(new ScoringCheck
{
Component = component,
Score = 0,
Weight = 0,
Message = message,
Passed = penaltyMultiplier >= 1.0,
IsPenalty = true,
PenaltyMultiplier = penaltyMultiplier
});
}
public string GenerateSummaryMessage()
{
if (Score == 0)
{
var earlyExit = Checks.FirstOrDefault(c => c.IsEarlyExit);
if (earlyExit != null)
{
return $"Score: 0 - {earlyExit.Message}";
}
}
var passedChecks = Checks.Where(c => c.Passed && !c.IsEarlyExit && !c.IsPenalty).ToList();
var failedChecks = Checks.Where(c => !c.Passed && !c.IsEarlyExit && !c.IsPenalty).ToList();
var penalties = Checks.Where(c => c.IsPenalty).ToList();
var summary = new StringBuilder();
summary.AppendLine($"Final Score: {Score:F1}/100");
if (passedChecks.Any())
{
summary.AppendLine($"✅ Passed Checks ({passedChecks.Count}):");
foreach (var check in passedChecks.OrderByDescending(c => c.Score * c.Weight))
{
summary.AppendLine($" • {check.Component}: {check.Score:F1} points ({check.Message})");
}
}
if (failedChecks.Any())
{
summary.AppendLine($"❌ Failed Checks ({failedChecks.Count}):");
foreach (var check in failedChecks)
{
summary.AppendLine($" • {check.Component}: {check.Message}");
}
}
if (penalties.Any())
{
summary.AppendLine($"⚠️ Applied Penalties ({penalties.Count}):");
foreach (var penalty in penalties)
{
summary.AppendLine($" • {penalty.Component}: {penalty.Message}");
}
}
return summary.ToString().TrimEnd();
}
}
public class ScoringCheck
{
public string Component { get; set; } = string.Empty;
public double Score { get; set; }
public double Weight { get; set; }
public string Message { get; set; } = string.Empty;
public bool Passed { get; set; }
public bool IsEarlyExit { get; set; }
public bool IsPenalty { get; set; }
public double PenaltyMultiplier { get; set; }
}

View File

@@ -16,4 +16,5 @@ public class LightBacktest
public decimal Fees { get; set; }
public double? SharpeRatio { get; set; }
public double Score { get; set; }
public string ScoreMessage { get; set; } = string.Empty;
}

View File

@@ -21,58 +21,101 @@ public class BacktestScorer
public static double CalculateTotalScore(BacktestScoringParams p)
{
var result = CalculateDetailedScore(p);
return result.Score;
}
public static BacktestScoringResult CalculateDetailedScore(BacktestScoringParams p)
{
var result = new BacktestScoringResult(0);
try
{
// Early exit for no positions
if (p.TradeCount == 0)
{
return 0;
result.AddEarlyExitCheck("No Trades", "No trading positions were taken during the backtest period. A strategy must execute trades to be evaluated.");
return result;
}
// Early exit for negative PnL - should be 0 score
if (p.TotalPnL <= 0)
{
return 0;
result.AddEarlyExitCheck("Negative PnL", $"Total profit/loss is negative (${p.TotalPnL:F2}). Only profitable strategies receive scores.");
return result;
}
// Early exit if strategy significantly underperforms HODL (more than 2% worse)
if (p.GrowthPercentage < p.HodlPercentage - 2)
{
return 0;
result.AddEarlyExitCheck("HODL Underperformance", $"Strategy growth ({p.GrowthPercentage:F2}%) significantly underperforms HODL ({p.HodlPercentage:F2}%) by more than 2%. Buy-and-hold would have been more profitable.");
return result;
}
var baseScore = CalculateBaseScore(p);
var finalScore = ApplyProfitabilityRules(baseScore, p);
var baseScore = CalculateBaseScore(p, result);
var finalScore = ApplyProfitabilityRules(baseScore, p, result);
return double.Clamp(finalScore, 0, 100);
result.Score = double.Clamp(finalScore, 0, 100);
result.SummaryMessage = result.GenerateSummaryMessage();
return result;
}
catch
catch (Exception ex)
{
return 0;
result.AddEarlyExitCheck("Calculation Error", $"An error occurred during score calculation: {ex.Message}");
return result;
}
}
private static double CalculateBaseScore(BacktestScoringParams p)
private static double CalculateBaseScore(BacktestScoringParams p, BacktestScoringResult result)
{
var componentScores = new Dictionary<string, double>
{
{ "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) }
{ "GrowthPercentage", CalculateGrowthScore(p.GrowthPercentage, result) },
{ "SharpeRatio", CalculateSharpeScore(p.SharpeRatio, result) },
{ "MaxDrawdownUsd", CalculateDrawdownUsdScore(p.MaxDrawdown, p.InitialBalance, result) },
{ "HodlComparison", CalculateHodlComparisonScore(p.GrowthPercentage, p.HodlPercentage, result) },
{ "WinRate", CalculateWinRateScore(p.WinRate, p.TradeCount, result) },
{ "ProfitabilityBonus", CalculateProfitabilityBonus(p.GrowthPercentage, result) },
{ "TradeCount", CalculateTradeCountScore(p.TradeCount, result) },
{ "RecoveryTime", CalculateRecoveryScore(p.MaxDrawdownRecoveryTime, p.Timeframe, result) },
{ "TestDuration", CalculateTestDurationScore(p.StartDate, p.EndDate, p.Timeframe, result) },
{ "FeesImpact", CalculateFeesImpactScore(p.FeesPaid, p.InitialBalance, (decimal)p.TotalPnL, result) },
{ "RiskAdjustedReturn", CalculateRiskAdjustedReturnScore(p.TotalPnL, p.MaxDrawdown, p.InitialBalance, result) }
};
return componentScores.Sum(kvp => kvp.Value * Weights[kvp.Key]);
var totalScore = componentScores.Sum(kvp => kvp.Value * Weights[kvp.Key]);
// Add component scores to result
foreach (var kvp in componentScores)
{
var passed = kvp.Value > 0;
result.AddCheck(kvp.Key, kvp.Value, Weights[kvp.Key], GetComponentMessage(kvp.Key, kvp.Value, p), passed);
}
return totalScore;
}
private static double ApplyProfitabilityRules(double baseScore, BacktestScoringParams p)
private static string GetComponentMessage(string component, double score, BacktestScoringParams p)
{
return component switch
{
"GrowthPercentage" => $"Growth of {p.GrowthPercentage:F2}% (target: 10% for full score)",
"SharpeRatio" => $"Sharpe ratio of {p.SharpeRatio:F2} (target: 4.0 for full score)",
"MaxDrawdownUsd" => $"Max drawdown of ${p.MaxDrawdown:F2} ({p.MaxDrawdownPc:F1}% of balance)",
"HodlComparison" => $"Strategy vs HODL: {p.GrowthPercentage:F2}% vs {p.HodlPercentage:F2}% (difference: {p.GrowthPercentage - p.HodlPercentage:F2}%)",
"WinRate" => $"Win rate of {p.WinRate * 100:F1}% with {p.TradeCount} trades",
"ProfitabilityBonus" => $"Bonus for positive growth of {p.GrowthPercentage:F2}%",
"TradeCount" => $"{p.TradeCount} trades executed (minimum 5, optimal 50+)",
"RecoveryTime" => $"Recovery time: {p.MaxDrawdownRecoveryTime.TotalDays:F1} days",
"TestDuration" => $"Test duration: {(p.EndDate - p.StartDate).TotalDays:F1} days",
"FeesImpact" => $"Fees: ${p.FeesPaid:F2} ({p.FeesPaid / p.InitialBalance * 100:F2}% of balance)",
"RiskAdjustedReturn" => $"PnL/Drawdown ratio: {p.TotalPnL / (double)p.MaxDrawdown:F2}:1",
_ => $"Component score: {score:F1}"
};
}
private static double ApplyProfitabilityRules(double baseScore, BacktestScoringParams p, BacktestScoringResult result)
{
var penaltyMultiplier = 1.0;
@@ -80,30 +123,36 @@ public class BacktestScorer
if (p.GrowthPercentage < 0)
{
var negativePenalty = Math.Abs(p.GrowthPercentage) * 0.1; // 10% penalty per 1% loss
penaltyMultiplier *= Math.Max(0.1, 1 - negativePenalty);
var newMultiplier = Math.Max(0.1, 1 - negativePenalty);
penaltyMultiplier *= newMultiplier;
result.AddPenaltyCheck("Negative Growth", newMultiplier, $"Negative growth of {p.GrowthPercentage:F2}% applied {negativePenalty:F1}% penalty");
}
// Note: Negative PnL is now handled by early exit in CalculateTotalScore
// 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);
var newMultiplier = Math.Max(0.2, 1 - winRatePenalty);
penaltyMultiplier *= newMultiplier;
result.AddPenaltyCheck("Low Win Rate", newMultiplier, $"Win rate of {p.WinRate * 100:F1}% below 30% threshold applied {winRatePenalty:F1}% penalty");
}
// 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);
var newMultiplier = Math.Max(0.5, 1 - profitPenalty);
penaltyMultiplier *= newMultiplier;
result.AddPenaltyCheck("Low Profit", newMultiplier, $"Growth of {p.GrowthPercentage:F2}% below 2% minimum applied {profitPenalty:F1}% penalty");
}
// 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);
var newMultiplier = Math.Max(0.3, 1 - drawdownPenalty);
penaltyMultiplier *= newMultiplier;
result.AddPenaltyCheck("High Drawdown", newMultiplier, $"Drawdown of {p.MaxDrawdownPc:F1}% above 20% threshold applied {drawdownPenalty:F1}% penalty");
}
// 6. Enhanced Drawdown vs PnL Penalty
@@ -113,7 +162,9 @@ public class BacktestScorer
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);
var newMultiplier = Math.Max(0.2, 1 - ratioPenalty);
penaltyMultiplier *= newMultiplier;
result.AddPenaltyCheck("Poor Risk/Reward", newMultiplier, $"Drawdown/PnL ratio of {drawdownToPnLRatio:F2}:1 above 1.5:1 threshold applied {ratioPenalty:F1}% penalty");
}
}
@@ -122,7 +173,9 @@ public class BacktestScorer
if (testDurationDays < 30)
{
var durationPenalty = (30 - testDurationDays) * 0.02; // 2% penalty per day below 30
penaltyMultiplier *= Math.Max(0.5, 1 - durationPenalty);
var newMultiplier = Math.Max(0.5, 1 - durationPenalty);
penaltyMultiplier *= newMultiplier;
result.AddPenaltyCheck("Short Test Duration", newMultiplier, $"Test duration of {testDurationDays:F1} days below 30-day minimum applied {durationPenalty:F1}% penalty");
}
// 8. HODL Underperformance Penalty (Dynamic)
@@ -130,13 +183,15 @@ public class BacktestScorer
{
var hodlUnderperformance = p.HodlPercentage - p.GrowthPercentage;
var hodlPenalty = hodlUnderperformance * 0.3; // 30% penalty per 1% underperformance
penaltyMultiplier *= Math.Max(0.1, 1 - hodlPenalty);
var newMultiplier = Math.Max(0.1, 1 - hodlPenalty);
penaltyMultiplier *= newMultiplier;
result.AddPenaltyCheck("HODL Underperformance", newMultiplier, $"Underperforming HODL by {hodlUnderperformance:F2}% applied {hodlPenalty:F1}% penalty");
}
return baseScore * penaltyMultiplier;
}
private static double CalculateGrowthScore(double growthPercentage)
private static double CalculateGrowthScore(double growthPercentage, BacktestScoringResult result)
{
// More aggressive scoring - harder to reach 100
if (growthPercentage < 0)
@@ -164,7 +219,7 @@ public class BacktestScorer
};
}
private static double CalculateProfitabilityBonus(double growthPercentage)
private static double CalculateProfitabilityBonus(double growthPercentage, BacktestScoringResult result)
{
return growthPercentage switch
{
@@ -173,7 +228,7 @@ public class BacktestScorer
};
}
private static double CalculateSharpeScore(double sharpeRatio)
private static double CalculateSharpeScore(double sharpeRatio, BacktestScoringResult result)
{
return sharpeRatio switch
{
@@ -183,7 +238,7 @@ public class BacktestScorer
};
}
private static double CalculateDrawdownUsdScore(decimal maxDrawdown, decimal initialBalance)
private static double CalculateDrawdownUsdScore(decimal maxDrawdown, decimal initialBalance, BacktestScoringResult result)
{
if (initialBalance <= 0) return 0;
@@ -195,7 +250,7 @@ public class BacktestScorer
};
}
private static double CalculateWinRateScore(double winRate, int tradeCount)
private static double CalculateWinRateScore(double winRate, int tradeCount, BacktestScoringResult result)
{
// Base win rate score
var baseScore = winRate * 100;
@@ -212,7 +267,7 @@ public class BacktestScorer
return baseScore * significanceFactor;
}
private static double CalculateHodlComparisonScore(double strategyGrowth, double hodlGrowth)
private static double CalculateHodlComparisonScore(double strategyGrowth, double hodlGrowth, BacktestScoringResult result)
{
var difference = strategyGrowth - hodlGrowth;
@@ -228,7 +283,7 @@ public class BacktestScorer
};
}
private static double CalculateTradeCountScore(int tradeCount)
private static double CalculateTradeCountScore(int tradeCount, BacktestScoringResult result)
{
return tradeCount switch
{
@@ -239,7 +294,7 @@ public class BacktestScorer
};
}
private static double CalculateRecoveryScore(TimeSpan recoveryTime, Timeframe timeframe)
private static double CalculateRecoveryScore(TimeSpan recoveryTime, Timeframe timeframe, BacktestScoringResult result)
{
var days = recoveryTime.TotalDays;
@@ -260,7 +315,7 @@ public class BacktestScorer
return 100 - (days / maxRecoveryDays * 100);
}
private static double CalculateTestDurationScore(DateTime startDate, DateTime endDate, Timeframe timeframe)
private static double CalculateTestDurationScore(DateTime startDate, DateTime endDate, Timeframe timeframe, BacktestScoringResult result)
{
var durationDays = (endDate - startDate).TotalDays;
@@ -283,7 +338,7 @@ public class BacktestScorer
return 100;
}
private static double CalculateFeesImpactScore(decimal feesPaid, decimal initialBalance, decimal totalPnL)
private static double CalculateFeesImpactScore(decimal feesPaid, decimal initialBalance, decimal totalPnL, BacktestScoringResult result)
{
if (initialBalance <= 0) return 0;
@@ -307,7 +362,7 @@ public class BacktestScorer
return feeEfficiency;
}
private static double CalculateRiskAdjustedReturnScore(double totalPnL, decimal maxDrawdown, decimal initialBalance)
private static double CalculateRiskAdjustedReturnScore(double totalPnL, decimal maxDrawdown, decimal initialBalance, BacktestScoringResult result)
{
if (initialBalance <= 0 || maxDrawdown <= 0) return 0;

View File

@@ -157,7 +157,8 @@ public class BacktestRepository : IBacktestRepository
MaxDrawdown = b.Statistics?.MaxDrawdown,
Fees = b.Fees,
SharpeRatio = b.Statistics?.SharpeRatio != null ? (double)b.Statistics.SharpeRatio : null,
Score = b.Score
Score = b.Score,
ScoreMessage = b.ScoreMessage ?? string.Empty
});
return (mappedBacktests, (int)totalCount);
@@ -284,7 +285,8 @@ public class BacktestRepository : IBacktestRepository
MaxDrawdown = b.Statistics?.MaxDrawdown,
Fees = b.Fees,
SharpeRatio = b.Statistics?.SharpeRatio != null ? (double)b.Statistics.SharpeRatio : null,
Score = b.Score
Score = b.Score,
ScoreMessage = b.ScoreMessage ?? string.Empty
});
return (mappedBacktests, (int)totalCount);

View File

@@ -29,6 +29,7 @@ namespace Managing.Infrastructure.Databases.MongoDb.Collections
[BsonRepresentation(BsonType.Decimal128)]
public decimal Fees { get; set; }
public double Score { get; set; }
public string ScoreMessage { get; set; } = string.Empty;
public string Identifier { get; set; }
public string RequestId { get; set; }
public string? Metadata { get; set; }

View File

@@ -150,6 +150,7 @@ public static class MongoMappers
StartDate = b.StartDate,
EndDate = b.EndDate,
Score = b.Score,
ScoreMessage = b.ScoreMessage ?? string.Empty,
RequestId = b.RequestId,
Metadata = string.IsNullOrEmpty(b.Metadata) ? null : JsonSerializer.Deserialize<object>(b.Metadata)
};
@@ -179,6 +180,7 @@ public static class MongoMappers
StartDate = result.StartDate,
EndDate = result.EndDate,
Score = result.Score,
ScoreMessage = result.ScoreMessage ?? string.Empty,
RequestId = result.RequestId,
Metadata = result.Metadata == null ? null : JsonSerializer.Serialize(result.Metadata)
};