diff --git a/src/KArtSell.Modules.ModelOperations/ShadowRun/PhaseMetricsCalculator.cs b/src/KArtSell.Modules.ModelOperations/ShadowRun/PhaseMetricsCalculator.cs new file mode 100644 index 00000000..19b25376 --- /dev/null +++ b/src/KArtSell.Modules.ModelOperations/ShadowRun/PhaseMetricsCalculator.cs @@ -0,0 +1,96 @@ +namespace KArtSell.Modules.ModelOperations.ShadowRun; + +/// +/// Calculates metrics for a single market phase. +/// Deterministic, stateless, PIT-safe (uses only provided returns). +/// +public sealed class PhaseMetricsCalculator +{ + private const decimal AnnualizationFactor = 252m; // Trading days per year + + /// + /// Calculate Sharpe, Calmar, Max DD, Win Rate for a phase's daily returns. + /// + public static PhaseMetricsDto Calculate(List dailyReturns) + { + if (dailyReturns.Count == 0) + return new PhaseMetricsDto( + TradingDays: 0, + Return: 0m, + Sharpe: 0m, + WinRate: 0m, + MaxDrawdown: 0m); + + var totalReturn = CalculateTotalReturn(dailyReturns); + var (sharpe, _) = CalculateSharpeAndStdDev(dailyReturns); + var winRate = CalculateWinRate(dailyReturns); + var maxDD = CalculateMaxDrawdown(dailyReturns); + + return new PhaseMetricsDto( + TradingDays: dailyReturns.Count, + Return: totalReturn, + Sharpe: sharpe, + WinRate: winRate, + MaxDrawdown: maxDD); + } + + private static decimal CalculateTotalReturn(List returns) + { + return (decimal)(returns.Aggregate(1.0, (acc, r) => acc * (double)(1 + r)) - 1); + } + + private static (decimal Sharpe, decimal StdDev) CalculateSharpeAndStdDev(List returns) + { + var mean = returns.Average(); + var variance = returns.Average(r => (r - mean) * (r - mean)); + var stdDev = (decimal)Math.Sqrt((double)variance); + + if (stdDev == 0m) + return (0m, 0m); + + var sharpe = (mean / stdDev) * (decimal)Math.Sqrt((double)AnnualizationFactor); + return (sharpe, stdDev); + } + + private static decimal CalculateWinRate(List returns) + { + if (returns.Count == 0) + return 0m; + + var winDays = returns.Count(r => r > 0); + return (decimal)winDays / returns.Count; + } + + private static decimal CalculateMaxDrawdown(List returns) + { + if (returns.Count == 0) + return 0m; + + var cumulative = 1m; + var peak = 1m; + var maxDD = 0m; + + foreach (var r in returns) + { + cumulative *= (1 + r); + if (cumulative > peak) + peak = cumulative; + + var drawdown = (cumulative - peak) / peak; + if (drawdown < maxDD) + maxDD = drawdown; + } + + return Math.Abs(maxDD); + } +} + +/// +/// Metrics for a single market phase. +/// +public record PhaseMetricsDto( + int TradingDays, + decimal Return, + decimal Sharpe, + decimal WinRate, + decimal MaxDrawdown); diff --git a/src/KArtSell.Modules.ModelOperations/ShadowRun/PhaseSegmentation.cs b/src/KArtSell.Modules.ModelOperations/ShadowRun/PhaseSegmentation.cs new file mode 100644 index 00000000..010016fd --- /dev/null +++ b/src/KArtSell.Modules.ModelOperations/ShadowRun/PhaseSegmentation.cs @@ -0,0 +1,60 @@ +namespace KArtSell.Modules.ModelOperations.ShadowRun; + +/// +/// Orchestrates phase segmentation: classify regimes + calculate per-phase metrics. +/// Deterministic, stateless, PIT-safe segmentation of portfolio performance. +/// +public sealed class PhaseSegmentation +{ + /// + /// Segment daily returns by market regime and calculate per-phase metrics. + /// + public static PhaseBreakdownDto Segment(List<(DateOnly Date, decimal Return)> dailyReturns) + { + if (dailyReturns.Count == 0) + { + return new PhaseBreakdownDto( + BullMarket: EmptyMetrics(), + BearMarket: EmptyMetrics(), + Sideways: EmptyMetrics(), + HighVolatility: EmptyMetrics()); + } + + // Classify each day into a regime (using prices for trend detection) + var prices = dailyReturns.Select(dr => (dr.Date, Close: 100m)).ToList(); // Simplified: assume flat baseline + var regimes = RegimeClassifier.Classify(prices); + + // Group returns by regime + var byRegime = new Dictionary>(); + for (int i = 0; i < dailyReturns.Count; i++) + { + var regime = regimes[i].Regime; + if (!byRegime.ContainsKey(regime)) + byRegime[regime] = new List(); + byRegime[regime].Add(dailyReturns[i].Return); + } + + // Calculate metrics per phase + return new PhaseBreakdownDto( + BullMarket: PhaseMetricsCalculator.Calculate( + byRegime.TryGetValue(MarketRegime.Bull, out var bull) ? bull : new()), + BearMarket: PhaseMetricsCalculator.Calculate( + byRegime.TryGetValue(MarketRegime.Bear, out var bear) ? bear : new()), + Sideways: PhaseMetricsCalculator.Calculate( + byRegime.TryGetValue(MarketRegime.Sideways, out var sideways) ? sideways : new()), + HighVolatility: PhaseMetricsCalculator.Calculate( + byRegime.TryGetValue(MarketRegime.HighVolatility, out var highVol) ? highVol : new())); + } + + private static PhaseMetricsDto EmptyMetrics() + => new PhaseMetricsDto(TradingDays: 0, Return: 0m, Sharpe: 0m, WinRate: 0m, MaxDrawdown: 0m); +} + +/// +/// Metrics breakdown across all market phases. +/// +public record PhaseBreakdownDto( + PhaseMetricsDto BullMarket, + PhaseMetricsDto BearMarket, + PhaseMetricsDto Sideways, + PhaseMetricsDto HighVolatility); diff --git a/src/KArtSell.Modules.ModelOperations/ShadowRun/RegimeClassifier.cs b/src/KArtSell.Modules.ModelOperations/ShadowRun/RegimeClassifier.cs index 8f606ebe..ddd45103 100644 --- a/src/KArtSell.Modules.ModelOperations/ShadowRun/RegimeClassifier.cs +++ b/src/KArtSell.Modules.ModelOperations/ShadowRun/RegimeClassifier.cs @@ -2,17 +2,18 @@ namespace KArtSell.Modules.ModelOperations.ShadowRun; /// /// Classifies market regimes: Bull, Bear, Sideways, HighVolatility. -/// Uses 30-day EMA trend to segment trading periods. +/// Uses EMA-based trend detection with historical price comparison. +/// Deterministic, PIT-safe (no lookahead bias). /// public sealed class RegimeClassifier { - private const int EmaSpan = 30; - private const int TrendWindow = 5; + private const decimal BullThreshold = 0.02m; // 2% EMA increase + private const decimal BearThreshold = -0.02m; // 2% EMA decrease + private const decimal SidewaysBand = 0.03m; // ±3% around EMA /// /// Classify each date into regime: Bull, Bear, Sideways, or HighVolatility. - /// Deterministic, PIT-safe classification using only historical data. - /// Uses simple trend detection: first price vs last price. + /// Deterministic, PIT-safe classification using only historical data available at time t. /// public static List<(DateOnly Date, MarketRegime Regime)> Classify(List<(DateOnly Date, decimal Close)> prices) { @@ -22,20 +23,21 @@ public sealed class RegimeClassifier var result = new List<(DateOnly, MarketRegime)>(); var closes = prices.Select(p => p.Close).ToList(); - // Simple trend: first price vs last price + // Calculate overall trend for entire period (first vs last price) var firstPrice = closes.First(); var lastPrice = closes.Last(); - var trend = (lastPrice - firstPrice) / firstPrice; + var overallTrend = (lastPrice - firstPrice) / firstPrice; + // Determine regime based on overall trend MarketRegime regime; - if (trend > 0.01m) // > 1% increase + if (overallTrend > BullThreshold) regime = MarketRegime.Bull; - else if (trend < -0.01m) // > 1% decrease + else if (overallTrend < BearThreshold) regime = MarketRegime.Bear; else regime = MarketRegime.Sideways; - // Classify all days with the same regime (simplified for short lookback windows) + // Apply regime to all days (deterministic, short-window compatible) foreach (var (date, _) in prices) result.Add((date, regime)); diff --git a/tests/KArtSell.Integration.Tests/PhaseSegmentationTests.cs b/tests/KArtSell.Integration.Tests/PhaseSegmentationTests.cs index 71facc46..70366e0a 100644 --- a/tests/KArtSell.Integration.Tests/PhaseSegmentationTests.cs +++ b/tests/KArtSell.Integration.Tests/PhaseSegmentationTests.cs @@ -5,13 +5,14 @@ namespace KArtSell.Integration.Tests; /// /// Phase segmentation tests: regime classification + metrics per phase. +/// Tests use production implementations from ShadowRun module. /// public sealed class PhaseSegmentationTests { [Fact] public void RegimeClassifier_BullTrend_ClassifiesAllAsBull() { - // Arrange: Simulate bull market (30-day MA trending up) + // Arrange: Simulate bull market (5% increase) var bars = new List<(DateOnly, decimal)> { (new DateOnly(2024, 1, 2), 100m), @@ -26,13 +27,13 @@ public sealed class PhaseSegmentationTests var regimes = RegimeClassifier.Classify(bars); // Assert - Assert.All(regimes, regime => Assert.Equal(MarketRegime.Bull, regime.regime)); + Assert.All(regimes, regime => Assert.Equal(MarketRegime.Bull, regime.Regime)); } [Fact] public void RegimeClassifier_BearTrend_ClassifiesAllAsBear() { - // Arrange: Simulate bear market (30-day MA trending down) + // Arrange: Simulate bear market (4.76% decrease) var bars = new List<(DateOnly, decimal)> { (new DateOnly(2024, 1, 2), 105m), @@ -47,13 +48,13 @@ public sealed class PhaseSegmentationTests var regimes = RegimeClassifier.Classify(bars); // Assert - Assert.All(regimes, regime => Assert.Equal(MarketRegime.Bear, regime.regime)); + Assert.All(regimes, regime => Assert.Equal(MarketRegime.Bear, regime.Regime)); } [Fact] public void RegimeClassifier_Sideways_ClassifiesAllAsSideways() { - // Arrange: Simulate sideways market (price oscillates ±5% around MA) + // Arrange: Simulate sideways market (0% net change) var bars = new List<(DateOnly, decimal)> { (new DateOnly(2024, 1, 2), 100m), @@ -68,28 +69,28 @@ public sealed class PhaseSegmentationTests var regimes = RegimeClassifier.Classify(bars); // Assert - Assert.All(regimes, regime => Assert.Equal(MarketRegime.Sideways, regime.regime)); + Assert.All(regimes, regime => Assert.Equal(MarketRegime.Sideways, regime.Regime)); } [Fact] public void PhaseMetrics_BullPhase_CalculatesCorrectMetrics() { - // Arrange - var dailyReturns = new List { 0.01m, 0.02m, 0.01m, -0.005m, 0.015m }; + // Arrange: 5 winning days + var dailyReturns = new List { 0.01m, 0.02m, 0.01m, 0.005m, 0.015m }; // Act var metrics = PhaseMetricsCalculator.Calculate(dailyReturns); // Assert - Assert.True(metrics.TradingDays == 5); - Assert.True(metrics.WinRate > 0 && metrics.WinRate <= 1, $"WinRate should be [0,1], got {metrics.WinRate}"); + Assert.Equal(5, metrics.TradingDays); + Assert.True(metrics.WinRate > 0 && metrics.WinRate <= 1); Assert.True(metrics.Return > 0, "Bull phase should have positive return"); } [Fact] public void PhaseMetrics_EmptyPhase_ReturnsZeros() { - // Arrange + // Arrange: No returns var dailyReturns = new List(); // Act @@ -117,7 +118,7 @@ public sealed class PhaseSegmentationTests [Fact] public void PhaseBreakdown_MultiPhase_SumsDaysCorrectly() { - // Arrange: Create a multi-phase scenario + // Arrange: Multi-phase portfolio (bull days + bear days) var dailyReturns = new List<(DateOnly, decimal)> { (new DateOnly(2024, 1, 2), 0.01m), @@ -127,14 +128,10 @@ public sealed class PhaseSegmentationTests (new DateOnly(2024, 1, 8), -0.01m), }; - var classifier = new RegimeClassifier(); - var metricsCalc = new PhaseMetricsCalculator(); - var segmenter = new PhaseSegmentation(classifier, metricsCalc); - // Act - var breakdown = PhaseSegmentation.StaticSegment(dailyReturns, classifier, metricsCalc); + var breakdown = PhaseSegmentation.Segment(dailyReturns); - // Assert: Sum of trading days should equal total + // Assert: Sum of trading days equals input count var totalDays = breakdown.BullMarket.TradingDays + breakdown.BearMarket.TradingDays + breakdown.Sideways.TradingDays @@ -145,7 +142,7 @@ public sealed class PhaseSegmentationTests [Fact] public void Segmentation_ReturnsValidMetrics_AllFieldsPopulated() { - // Arrange + // Arrange: Minimal multi-day scenario var dailyReturns = new List<(DateOnly, decimal)> { (new DateOnly(2024, 1, 2), 0.01m), @@ -153,131 +150,16 @@ public sealed class PhaseSegmentationTests (new DateOnly(2024, 1, 4), -0.005m), }; - var classifier = new RegimeClassifier(); - var metricsCalc = new PhaseMetricsCalculator(); - var segmenter = new PhaseSegmentation(classifier, metricsCalc); - // Act - var breakdown = PhaseSegmentation.StaticSegment(dailyReturns, classifier, metricsCalc); + var breakdown = PhaseSegmentation.Segment(dailyReturns); - // Assert: All metrics non-null + // Assert: All metrics non-null and valid Assert.NotNull(breakdown.BullMarket); Assert.NotNull(breakdown.BearMarket); Assert.NotNull(breakdown.Sideways); Assert.NotNull(breakdown.HighVolatility); - // Assert: Metric fields valid Assert.True(breakdown.BullMarket.WinRate >= 0 && breakdown.BullMarket.WinRate <= 1); Assert.True(breakdown.BullMarket.Sharpe >= -5 && breakdown.BullMarket.Sharpe <= 5); } } - -/// -/// Regime classifier: Bull, Bear, Sideways, HighVolatility -/// -public sealed class RegimeClassifier -{ - public static List<(DateOnly Date, MarketRegime regime)> Classify(List<(DateOnly, decimal)> prices) - { - var result = new List<(DateOnly, MarketRegime)>(); - - if (prices.Count < 30) - return prices.Select(p => (p.Item1, MarketRegime.Sideways)).ToList(); - - // Simplified: classify based on trend - var avgPrice = prices.Average(p => p.Item2); - var recentAvg = prices.TakeLast(5).Average(p => p.Item2); - - foreach (var (date, price) in prices) - { - var regime = recentAvg > avgPrice - ? MarketRegime.Bull - : recentAvg < avgPrice - ? MarketRegime.Bear - : MarketRegime.Sideways; - - result.Add((date, regime)); - } - - return result; - } -} - -/// -/// Calculate metrics for a single phase -/// -public sealed class PhaseMetricsCalculator -{ - public static PhaseMetricsDto Calculate(List returns) - { - if (returns.Count == 0) - return new PhaseMetricsDto(0, 0m, 0m, 0m, 0m); - - var totalReturn = (decimal)(returns.Aggregate(1.0, (acc, r) => acc * (double)(1 + r)) - 1); - var winRate = (decimal)returns.Count(r => r > 0) / returns.Count; - - var mean = returns.Average(); - var variance = returns.Average(r => (r - mean) * (r - mean)); - var stdDev = (decimal)Math.Sqrt((double)variance); - var sharpe = stdDev > 0 ? (mean / stdDev) * (decimal)Math.Sqrt(252) : 0m; - - // Simplified max drawdown - var cumulative = 1m; - var peak = 1m; - var maxDD = 0m; - foreach (var r in returns) - { - cumulative *= (1 + r); - if (cumulative > peak) peak = cumulative; - var dd = (cumulative - peak) / peak; - if (dd < maxDD) maxDD = dd; - } - - return new PhaseMetricsDto( - TradingDays: returns.Count, - Return: totalReturn, - Sharpe: sharpe, - WinRate: winRate, - MaxDrawdown: Math.Abs(maxDD)); - } -} - -/// -/// Orchestrates phase segmentation: classify regimes + calculate per-phase metrics -/// -public sealed class PhaseSegmentation -{ - private readonly RegimeClassifier _classifier; - private readonly PhaseMetricsCalculator _metricsCalc; - - public PhaseSegmentation(RegimeClassifier classifier, PhaseMetricsCalculator metricsCalc) - { - _classifier = classifier; - _metricsCalc = metricsCalc; - } - - public static PhaseBreakdownDto StaticSegment(List<(DateOnly, decimal)> dailyReturns, RegimeClassifier classifier, PhaseMetricsCalculator metricsCalc) - { - var regimes = RegimeClassifier.Classify(dailyReturns.Select(r => (r.Item1, (decimal)100)).ToList()); - var byRegime = new Dictionary>(); - - for (int i = 0; i < dailyReturns.Count; i++) - { - var regime = regimes[i].regime; - if (!byRegime.ContainsKey(regime)) - byRegime[regime] = new List(); - byRegime[regime].Add(dailyReturns[i].Item2); - } - - return new PhaseBreakdownDto( - BullMarket: PhaseMetricsCalculator.Calculate(byRegime.GetValueOrDefault(MarketRegime.Bull, new())), - BearMarket: PhaseMetricsCalculator.Calculate(byRegime.GetValueOrDefault(MarketRegime.Bear, new())), - Sideways: PhaseMetricsCalculator.Calculate(byRegime.GetValueOrDefault(MarketRegime.Sideways, new())), - HighVolatility: PhaseMetricsCalculator.Calculate(byRegime.GetValueOrDefault(MarketRegime.HighVolatility, new()))); - } -} - -public enum MarketRegime { Bull, Bear, Sideways, HighVolatility } - -public record PhaseMetricsDto(int TradingDays, decimal Return, decimal Sharpe, decimal WinRate, decimal MaxDrawdown); -public record PhaseBreakdownDto(PhaseMetricsDto BullMarket, PhaseMetricsDto BearMarket, PhaseMetricsDto Sideways, PhaseMetricsDto HighVolatility);