using Serilog; using PineScriptV6; using System.Diagnostics; // ── Serilog 초기화 ────────────────────────────────────────────────────────── Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .WriteTo.Async(a => a.File( path: "logs/pine_.log", rollingInterval: RollingInterval.Day, buffered: true, flushToDiskInterval: TimeSpan.FromSeconds(1), outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}")) .CreateLogger(); // ════════════════════════════════════════════════════════════════ // ★ 분석 기간 설정 (여기서 날짜 범위를 변경하세요) // ════════════════════════════════════════════════════════════════ DateTimeOffset dateFrom = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.FromHours(0)); DateTimeOffset dateTo = new DateTimeOffset(2025, 1, 31, 23, 59, 59, TimeSpan.FromHours(0)); // ── 매매 핸들러 등록 (로그만, 실제 구현은 나중에) ────────────────────────── TradingSignal.OnBuy = (price) => { Log.Information("★ [BUY 실행] 롱 진입 | 현재가: {Price}", price); // TODO: await BrokerApi.BuyAsync(symbol, qty, price); }; TradingSignal.OnSell = (price) => { Log.Information("★ [SELL 실행] 롱 청산 | 현재가: {Price}", price); // TODO: await BrokerApi.SellAsync(symbol, qty, price); }; TradingSignal.OnShort = (price) => { Log.Information("★ [SHORT 실행] 숏 진입 | 현재가: {Price}", price); // TODO: await BrokerApi.ShortAsync(symbol, qty, price); }; TradingSignal.OnCover = (price) => { Log.Information("★ [COVER 실행] 숏 청산 | 현재가: {Price}", price); // TODO: await BrokerApi.CoverAsync(symbol, qty, price); }; // ── 실행 ──────────────────────────────────────────────────────────────────── try { Stopwatch sw = Stopwatch.StartNew(); // 1) CSV 로드 (전체) const string csvPath = "./BITGET_XRPUSDT, 30.csv"; List allCandles = CsvLoader.Load(csvPath); if (allCandles.Count == 0) { Log.Error("봉 데이터가 없습니다. CSV 파일을 확인하세요: {Path}", csvPath); return; } // ── 워밍업 + 분석 구간 슬라이싱 ───────────────────────────── // 지표 최대 필요 봉 수: // SMA72 → 72봉 // 일목구름 span2 → 52봉 + displacement 26 = 78봉 // rangeThreshold → ta.sma(longRatio, 240) + lowest 12 = 252봉 // 여유분 포함 → 300봉 const int WarmupBars = 300; // dateFrom 바로 앞 인덱스를 찾아 warmup 시작점 결정 int fromIdx = allCandles.FindIndex(c => c.Time >= dateFrom); if (fromIdx < 0) { Log.Error("분석 기간 내 봉 데이터가 없습니다. dateFrom 을 확인하세요."); return; } int startIdx = Math.Max(0, fromIdx - WarmupBars); // dateTo 까지만 포함 List candles = allCandles .Skip(startIdx) .TakeWhile(c => c.Time <= dateTo) .ToList(); int warmupCount = fromIdx - startIdx; int rangeCount = candles.Count - warmupCount; Log.Information("CSV 전체: {Total}봉", allCandles.Count); Log.Information("실행 봉: {Exec}봉 (워밍업 {Warmup}봉 + 분석 {Range}봉)", candles.Count, warmupCount, rangeCount); Log.Information("분석 기간: {From} ~ {To}", dateFrom.ToString("yyyy-MM-dd"), dateTo.ToString("yyyy-MM-dd")); if (rangeCount <= 0) { Log.Error("분석 기간 내 봉 데이터가 없습니다. dateFrom/dateTo 를 확인하세요."); return; } // 2) Pine Script 실행 — 워밍업 포함 구간만 실행 // signalFrom 이후 봉에만 매 봉 상세 추적 로그가 출력됩니다 var executor = new PineScriptExecutor("./example.pine"); executor.ExecuteOnCandles(candles, signalFrom: dateFrom); // 3) 날짜 범위 필터 후 결과 출력 (워밍업 중 발생한 신호 제거) var filteredResults = TradingSignal.Results .Where(r => r.Time >= dateFrom && r.Time <= dateTo) .ToList(); sw.Stop(); PrintAndSaveResults(filteredResults, dateFrom, dateTo); } catch (Exception ex) { Log.Fatal(ex, "예상치 못한 오류가 발생했습니다."); } finally { Log.CloseAndFlush(); } // ── 결과 출력 메서드 ───────────────────────────────────────────────────────── static void PrintAndSaveResults(List results, DateTimeOffset dateFrom, DateTimeOffset dateTo) { string separator = " " + new string('-', 56); Log.Information(""); Log.Information("══════════════════════════════════════════════════════════"); Log.Information(" 매매 신호 결과 요약 ({From} ~ {To})", dateFrom.ToString("yyyy-MM-dd"), dateTo.ToString("yyyy-MM-dd")); Log.Information("══════════════════════════════════════════════════════════"); if (results.Count == 0) { Log.Information(" 감지된 신호 없음"); Log.Information("══════════════════════════════════════════════════════════"); return; } // 헤더 Log.Information(" {0,-20} {1,-12} {2,10}", "시간", "신호", "가격"); Log.Information(separator); int buyCount = 0, sellCount = 0, shortCount = 0, coverCount = 0; foreach (var e in results) { Log.Information(" {0,-20} {1,-12} {2,10:F4}", e.Time.ToString("yyyy-MM-dd HH:mm"), e.Signal.ToString(), e.Price); switch (e.Signal) { case SignalType.Buy: buyCount++; break; case SignalType.Sell: sellCount++; break; case SignalType.Short: shortCount++; break; case SignalType.Cover: coverCount++; break; } } Log.Information(separator); Log.Information(" 합계 | BUY={Buy} SELL={Sell} SHORT={Short} COVER={Cover} TOTAL={Total}", buyCount, sellCount, shortCount, coverCount, results.Count); Log.Information("══════════════════════════════════════════════════════════"); SaveResultsToCsv(results); } static void SaveResultsToCsv(List results) { string outputPath = "signal_results.csv"; // TradingView Strategy Tester는 신호 발생 봉의 '다음 봉 Open'에서 체결을 기록합니다. // 30분봉 기준으로 +30분 = TV 기준 체결 시각(tv_time). const int BarMinutes = 30; if (File.Exists(outputPath)) { File.Delete(outputPath); Log.Debug(" 기존 결과 파일 삭제: {Path}", outputPath); } var lines = new List { "time,signal,price,tv_time" }; lines.AddRange(results.Select(e => { var tvTime = e.Time.AddMinutes(BarMinutes); return $"{e.Time:yyyy-MM-dd HH:mm},{e.Signal},{e.Price:F4},{tvTime:yyyy-MM-dd HH:mm}"; })); File.WriteAllLines(outputPath, lines); Log.Information(" 결과 CSV 저장 완료: {Path} ({Count}건)", outputPath, results.Count); }