using Xunit; using KArtSell.BuildingBlocks.Time; using KArtSell.Modules.ModelOperations.ShadowRun; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace KArtSell.Integration.Tests; /// /// Phase 1-2 로컬 실행 (Host 미필요, 즉시 결과) /// - 252 거래일 시뮬레이션 /// - 메트릭 계산 /// - 게이트 검증 /// - 결과 요약 /// public sealed class Phase1Phase2LocalExecutionTests { private readonly ILogger _replayLogger = new NoOpLogger(); private readonly ILogger _metricsLogger = new NoOpLogger(); /// /// 252 거래일 실제 시뮬레이션 실행 (개선된 모델) /// Phase 1 + Phase 2 통합 테스트 /// [Fact] public async Task ExecutePhase1AndPhase2_FullYearSimulation() { // Arrange: 252일 실제 데이터 생성 var startDate = new DateOnly(2025, 8, 12); var endDate = new DateOnly(2026, 8, 12); var bars = Generate252TradingDaysData(startDate, endDate); var sessions = bars.Select(b => b.Date).Distinct().OrderBy(d => d).ToList(); var fees = new List { new(new DateOnly(2025, 8, 1), 0.001m, 0.0005m), }; var initialCapital = 10_000_000m; // Act: Phase 1 - ReplayAsync (252 거래일) var startTime = DateTime.UtcNow; var replay = new ReplayEngine(_replayLogger); var result = await replay.ReplayAsync( Guid.NewGuid(), bars, fees, initialCapital, sessions, CancellationToken.None); var phase1Duration = DateTime.UtcNow - startTime; // Act: Phase 2 - Metrics (자동 계산) var calculator = new MetricsCalculator(_metricsLogger); var metrics = await calculator.CalculateAsync( result, bars, fees, CancellationToken.None); // Assert & Report Assert.NotNull(result); Assert.NotNull(metrics); // Phase 1 검증 Assert.True(result.DailyReturns.Count > 0, "Should have daily returns"); Assert.True(result.Signals.Count > 0, "Should have signals from EMA"); Assert.True(result.Orders.Count > 0, "Should have orders from signals"); // Phase 2 게이트 검증 var pboPass = metrics.ProbOfBacktestOverfit <= 0.20m; var dsrPass = metrics.DailySharePercentile >= 0.95m; var costPass = metrics.TotalReturn > 0m; var allGatesPassed = pboPass && dsrPass && costPass; // 결과 출력 var separator = new string('=', 70); Console.WriteLine("\n" + separator); Console.WriteLine("🎯 PHASE 1-2 로컬 실행 완료"); Console.WriteLine(separator); Console.WriteLine($"\n📊 Phase 1 결과 (252 거래일):"); Console.WriteLine($" 실행 시간: {phase1Duration.TotalSeconds:F2} 초"); Console.WriteLine($" 거래일: {sessions.Count}"); Console.WriteLine($" 신호 생성: {result.Signals.Count}"); Console.WriteLine($" 주문 체결: {result.Orders.Count}"); Console.WriteLine($" 포트폴리오 스냅샷: {result.PortfolioHistory.Count}"); var finalValue = result.PortfolioHistory[result.PortfolioHistory.Count - 1].TotalValue; var totalReturn = (finalValue - initialCapital) / initialCapital; Console.WriteLine($"\n💰 P&L:"); Console.WriteLine($" 초기 자본: ${initialCapital:N0}"); Console.WriteLine($" 최종 가치: ${finalValue:N0}"); Console.WriteLine($" 총 수익률: {(totalReturn * 100):F2}%"); Console.WriteLine($"\n📈 Phase 2 메트릭:"); Console.WriteLine($" Total Return: {(metrics.TotalReturn * 100):F2}%"); Console.WriteLine($" Sharpe Ratio: {metrics.SharpeRatio:F4}"); Console.WriteLine($" PBO (Prob of Backtest Overfit): {(metrics.ProbOfBacktestOverfit * 100):F2}%"); Console.WriteLine($" DSR (Daily Sharpe Percentile): {(metrics.DailySharePercentile * 100):F2}%"); Console.WriteLine($"\n🎯 Phase 2 게이트 검증:"); Console.WriteLine($" Gate 1 (PBO ≤ 20%): {(pboPass ? "✅" : "❌")} ({(metrics.ProbOfBacktestOverfit * 100):F1}%)"); Console.WriteLine($" Gate 2 (DSR ≥ 95%): {(dsrPass ? "✅" : "❌")} ({(metrics.DailySharePercentile * 100):F1}%)"); Console.WriteLine($" Gate 3 (Cost > 0): {(costPass ? "✅" : "❌")} ({(metrics.TotalReturn * 100):F1}%)"); Console.WriteLine($"\n{(allGatesPassed ? "✅" : "⚠️")} AllGatesPassed: {allGatesPassed}"); Console.WriteLine(new string('=', 70)); Console.WriteLine($"\n📋 다음 단계:"); if (allGatesPassed) { Console.WriteLine(" ✅ Phase 3 OOS 검증 준비 완료"); Console.WriteLine(" → Hangfire 21:00 KST 자동 실행 시 바로 Phase 3 진행"); } else { Console.WriteLine(" ⚠️ Phase 3 차단 (게이트 미통과)"); Console.WriteLine($" → 모델 추가 튜닝 필요"); Console.WriteLine($" → Gate 1: PBO {(metrics.ProbOfBacktestOverfit * 100):F1}% (need ≤20%)"); Console.WriteLine($" → Gate 2: DSR {(metrics.DailySharePercentile * 100):F1}% (need ≥95%)"); } } private List Generate252TradingDaysData(DateOnly start, DateOnly end) { var bars = new List(); var random = new Random(42); var basePrice = 2500m; var currentPrice = basePrice; int tradingDay = 0; for (int calendarDay = 0; calendarDay < 400 && tradingDay < 252; calendarDay++) { var date = start.AddDays(calendarDay); if (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday) continue; if (date > end) break; // Realistic price: ±2% daily drift + trend var dailyReturn = (decimal)((random.NextDouble() - 0.5) * 0.04); var trend = (calendarDay % 252) < 126 ? 0.0001m : -0.00005m; currentPrice = currentPrice * (1m + dailyReturn + trend); currentPrice = Math.Max(2000m, currentPrice); bars.Add(new DataBackfiller.OhlcvBar( date, "KOSPI", currentPrice * 0.99m, currentPrice * 1.01m, currentPrice * 0.98m, currentPrice, 1_000_000L)); tradingDay++; } return bars; } private sealed class NoOpLogger : ILogger { public IDisposable? BeginScope(TState state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => false; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { } } }