using System.Collections.Immutable; using Microsoft.Extensions.Logging; namespace KArtSell.Modules.ModelOperations.ShadowRun; /// /// Calculates performance metrics from replay results. /// Implements: Sharpe, Calmar, Max Drawdown, Win Rate, PBO, DSR. /// public sealed class MetricsCalculator(ILogger logger) { private const decimal RiskFreeRate = 0.02m; // 2% annual private const int TradingDaysPerYear = 252; /// /// Calculate all metrics from replay results. /// public async Task CalculateAsync( ReplayResult replay, IReadOnlyList ohlcvBars, IReadOnlyList feeSchedule, CancellationToken cancellationToken) { await Task.Delay(10, cancellationToken); // Async marker logger.LogInformation( "Calculating metrics for {OrderCount} orders, {TradingDays} days", replay.Orders.Count, replay.DailyReturns.Count); var dailyReturns = replay.DailyReturns.ToList(); if (dailyReturns.Count < TradingDaysPerYear) { logger.LogWarning("Insufficient data for annual metrics: {DayCount} < {MinDays}", dailyReturns.Count, TradingDaysPerYear); } var totalReturn = CalculateTotalReturn(replay.PortfolioHistory); var sharpe = CalculateSharpeRatio(dailyReturns); var calmar = CalculateCalmarRatio(totalReturn, dailyReturns); var maxDD = CalculateMaxDrawdown(replay.PortfolioHistory); var winRate = CalculateWinRate(dailyReturns); var pbo = CalculatePbo(dailyReturns); var dsr = CalculateDailySharePercentile(dailyReturns); var metrics = new ShadowRunMetrics( TotalReturn: totalReturn, SharpeRatio: sharpe, CalmurRatio: calmar, MaximumDrawdown: maxDD, WinRate: winRate, ProbOfBacktestOverfit: pbo, DailySharePercentile: dsr, TradingDays: dailyReturns.Count); logger.LogInformation( "Metrics calculated: Return={Return:P}, Sharpe={Sharpe:F2}, PBO={Pbo:P}, DSR={Dsr:P}", metrics.TotalReturn, metrics.SharpeRatio, metrics.ProbOfBacktestOverfit, metrics.DailySharePercentile); return metrics; } private decimal CalculateTotalReturn(IReadOnlyList history) { if (history.Count == 0) return 0; var start = history[0].TotalValue; var end = history[^1].TotalValue; return (end - start) / start; } private decimal CalculateSharpeRatio(List<(DateOnly Date, decimal Return)> dailyReturns) { if (dailyReturns.Count < 2) return 0; var mean = dailyReturns.Average(r => r.Return); var variance = dailyReturns.Average(r => (r.Return - mean) * (r.Return - mean)); var stdDev = (decimal)Math.Sqrt((double)variance); if (stdDev == 0) return 0; var dailyRiskFreeRate = (RiskFreeRate / TradingDaysPerYear); var excessReturn = mean - dailyRiskFreeRate; var annualizedSharpe = (excessReturn / stdDev) * (decimal)Math.Sqrt(TradingDaysPerYear); return annualizedSharpe; } private decimal CalculateCalmarRatio(decimal totalReturn, List<(DateOnly Date, decimal Return)> dailyReturns) { var maxDD = CalculateMaxDrawdownFromReturns(dailyReturns); if (maxDD == 0) return 0; var annualizedReturn = totalReturn * (TradingDaysPerYear / dailyReturns.Count); return annualizedReturn / Math.Abs(maxDD); } private decimal CalculateMaxDrawdown(IReadOnlyList history) { if (history.Count == 0) return 0; decimal maxValue = history[0].TotalValue; decimal maxDD = 0; foreach (var portfolio in history) { if (portfolio.TotalValue > maxValue) maxValue = portfolio.TotalValue; var dd = (portfolio.TotalValue - maxValue) / maxValue; if (dd < maxDD) maxDD = dd; } return Math.Abs(maxDD); } private decimal CalculateMaxDrawdownFromReturns(List<(DateOnly Date, decimal Return)> dailyReturns) { if (dailyReturns.Count == 0) return 0; decimal cumValue = 1; decimal maxValue = 1; decimal maxDD = 0; foreach (var (_, ret) in dailyReturns) { cumValue *= (1 + ret); if (cumValue > maxValue) maxValue = cumValue; var dd = (cumValue - maxValue) / maxValue; if (dd < maxDD) maxDD = dd; } return Math.Abs(maxDD); } private decimal CalculateWinRate(List<(DateOnly Date, decimal Return)> dailyReturns) { if (dailyReturns.Count == 0) return 0; var wins = dailyReturns.Count(r => r.Return > 0); return (decimal)wins / dailyReturns.Count; } private decimal CalculatePbo(List<(DateOnly Date, decimal Return)> dailyReturns) { // Simplified PBO: out-of-sample Sharpe regression slope // Full implementation: partition into 5-fold CV, measure slope of test Sharpe vs. fold if (dailyReturns.Count < TradingDaysPerYear * 2) return 0.5m; // Default high PBO if insufficient data var mid = dailyReturns.Count / 2; var inSampleSharpe = CalculateSharpeRatio(dailyReturns.Take(mid).ToList()); var outOfSampleSharpe = CalculateSharpeRatio(dailyReturns.Skip(mid).ToList()); // PBO = max(0, 1 - (OOS Sharpe / IS Sharpe)) if (inSampleSharpe == 0) return 0.5m; var ratio = outOfSampleSharpe / inSampleSharpe; var pbo = Math.Max(0, 1 - ratio); return Math.Min(1, pbo); // Clamp to [0, 1] } private decimal CalculateDailySharePercentile(List<(DateOnly Date, decimal Return)> dailyReturns) { if (dailyReturns.Count == 0) return 0; var sharpe = CalculateSharpeRatio(dailyReturns); // Simplified: map Sharpe to percentile (empirical distribution) // Full: compare against historical model population if (sharpe < 0) return 0.05m; if (sharpe < 0.5m) return 0.30m; if (sharpe < 1.0m) return 0.60m; if (sharpe < 1.5m) return 0.80m; if (sharpe < 2.0m) return 0.95m; return 0.99m; } }