diff --git a/TriggerHangfireJob.cs b/TriggerHangfireJob.cs
new file mode 100644
index 00000000..68ee7eb0
--- /dev/null
+++ b/TriggerHangfireJob.cs
@@ -0,0 +1,81 @@
+using Npgsql;
+using System;
+using System.Threading.Tasks;
+
+class HangfireTrigger
+{
+ static async Task Main()
+ {
+ var connectionString = "Host=127.0.0.1;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!";
+
+ Console.WriteLine("🔍 Hangfire 수동 트리거 시작...");
+ Console.WriteLine($" DB: kartselldb");
+ Console.WriteLine($" Job ID: historical-batch-shadow-run");
+
+ try
+ {
+ using (var conn = new NpgsqlConnection(connectionString))
+ {
+ await conn.OpenAsync();
+ Console.WriteLine("✅ DB 연결 성공");
+
+ // 1. 현재 job 상태 확인
+ Console.WriteLine("\n1️⃣ 현재 Hangfire recurring job 상태:");
+ using (var cmd = new NpgsqlCommand(
+ "SELECT recurringjobid, cron, queue, nextexecutiontickcount FROM hangfire.recurringjob WHERE recurringjobid = @jobId",
+ conn))
+ {
+ cmd.Parameters.AddWithValue("@jobId", "historical-batch-shadow-run");
+ using (var reader = await cmd.ExecuteReaderAsync())
+ {
+ if (await reader.ReadAsync())
+ {
+ Console.WriteLine($" Job ID: {reader.GetString(0)}");
+ Console.WriteLine($" Cron: {reader.GetString(1)}");
+ Console.WriteLine($" Queue: {reader.GetString(2)}");
+ Console.WriteLine($" NextExecutionTickCount: {reader.GetInt64(3)}");
+ }
+ else
+ {
+ Console.WriteLine(" ❌ Job not found!");
+ return;
+ }
+ }
+ }
+
+ // 2. Job 트리거 (nextexecutiontickcount = 0으로 설정)
+ Console.WriteLine("\n2️⃣ Job 즉시 실행 트리거...");
+ using (var cmd = new NpgsqlCommand(
+ "UPDATE hangfire.recurringjob SET nextexecutiontickcount = 0 WHERE recurringjobid = @jobId",
+ conn))
+ {
+ cmd.Parameters.AddWithValue("@jobId", "historical-batch-shadow-run");
+ var rows = await cmd.ExecuteNonQueryAsync();
+ Console.WriteLine($"✅ {rows} row(s) 업데이트됨");
+ }
+
+ // 3. 업데이트 확인
+ Console.WriteLine("\n3️⃣ 업데이트 확인:");
+ using (var cmd = new NpgsqlCommand(
+ "SELECT nextexecutiontickcount FROM hangfire.recurringjob WHERE recurringjobid = @jobId",
+ conn))
+ {
+ cmd.Parameters.AddWithValue("@jobId", "historical-batch-shadow-run");
+ var result = await cmd.ExecuteScalarAsync();
+ Console.WriteLine($" NextExecutionTickCount: {result}");
+ }
+
+ Console.WriteLine("\n✅ Hangfire job 트리거 완료!");
+ Console.WriteLine(" - Hangfire 서비스가 실행 중이면 약 1분 내에 job 시작");
+ Console.WriteLine(" - Phase 1: 252 거래일 (8.6초)");
+ Console.WriteLine(" - Phase 2: 메트릭 계산 (5분)");
+ Console.WriteLine(" - Phase 3: 게이트 통과 시 자동 실행");
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"❌ 오류: {ex.Message}");
+ Console.WriteLine(ex.StackTrace);
+ }
+ }
+}
diff --git a/src/KArtSell.Host/appsettings.Development.json b/src/KArtSell.Host/appsettings.Development.json
index 9e76dd5c..1765dd6e 100644
--- a/src/KArtSell.Host/appsettings.Development.json
+++ b/src/KArtSell.Host/appsettings.Development.json
@@ -1,5 +1,8 @@
{
"Authentication": {
"Mode": "DevelopmentHeader"
+ },
+ "ConnectionStrings": {
+ "Postgres": "Host=127.0.0.1;Port=5432;Database=kartselldb;Username=kartsell;Password=kartsell4321@!"
}
}
diff --git a/tests/KArtSell.Integration.Tests/Phase1Phase2LocalExecutionTests.cs b/tests/KArtSell.Integration.Tests/Phase1Phase2LocalExecutionTests.cs
new file mode 100644
index 00000000..63a6131c
--- /dev/null
+++ b/tests/KArtSell.Integration.Tests/Phase1Phase2LocalExecutionTests.cs
@@ -0,0 +1,174 @@
+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("="*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) { }
+ }
+}