191 lines
8.1 KiB
C#
191 lines
8.1 KiB
C#
|
|
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<CandleData> 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<CandleData> 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<TradeSignalEvent> 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<TradeSignalEvent> 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<string> { "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);
|
||
|
|
}
|