using System.Diagnostics; using Serilog; using Serilog.Events; namespace PineScriptV6 { // ════════════════════════════════════════════════════════════════ // 문 (Statement) 타입 정의 // ════════════════════════════════════════════════════════════════ /// Pine Script 한 문(statement)의 추상 기반 클래스 public abstract record PineStmt; /// var bool inLong = false → 최초 1회만 초기화 public record VarDeclStmt(string Name, string TypeHint, string Expr) : PineStmt; /// sma4 = expr 또는 newLongSignal = false public record AssignStmt(string Name, string Expr) : PineStmt; /// inLong := true (상태 변수 재할당) public record ReassignStmt(string Name, string Expr) : PineStmt; /// plotshape(...), bgcolor(...) 등 반환값 없는 호출 public record ExprStmt(string Expr) : PineStmt; /// if condition\n body public record IfStmt(string Condition, List ThenBody, List? ElseBody) : PineStmt; // ════════════════════════════════════════════════════════════════ // 실행기 // ════════════════════════════════════════════════════════════════ public class PineScriptExecutor { private readonly string _filePath; private List? _program; // 최초 1회 파싱 private DependencyAnalyzer.ExecutionPlan? _plan; // 최초 1회 분석 // 실시간 모드용 — WarmUp 이후 ProcessCandle에서 사용 private int _realtimeBarIndex = 0; public PineScriptExecutor(string filePath = "./example.pine") { _filePath = filePath; } // ════════════════════════════════════════════════════════════════ // 배치 모드 (CSV 히스토리 전체 실행) // ════════════════════════════════════════════════════════════════ /// /// 봉 데이터를 순서대로 주입하며 매 봉마다 스크립트를 실행합니다. /// Two-Pass: Pass1(지표) → Pass2(상태머신) 순으로 실행. /// signalFrom: 이 시각 이후 봉에만 상세 추적 로그를 출력합니다. /// public void ExecuteOnCandles(List candles, DateTimeOffset? signalFrom = null) { ResetState(); PineEvaluator.ResetStats(); EnsurePlan(); if (_program!.Count == 0) { Log.Error("[Executor] 스크립트 로드 실패: {Path}", _filePath); return; } Log.Information("[Executor] Two-Pass 실행 시작 | 총 {Count}봉 " + "(Pass1={P1} Pass2={P2}) | 추적={From}", candles.Count, _plan!.Pass1Stmts.Count, _plan.Pass2Stmts.Count, signalFrom.HasValue ? signalFrom.Value.ToString("yyyy-MM-dd") : "전체"); long swPass1Ms = 0, swPass2Ms = 0, swInjectMs = 0, swLogMs = 0; var sw = Stopwatch.StartNew(); for (int i = 0; i < candles.Count; i++) { CandleData c = candles[i]; bool inSignalRange = !signalFrom.HasValue || c.Time >= signalFrom.Value; var t0 = sw.ElapsedTicks; InjectOhlcv(c, i); var t1 = sw.ElapsedTicks; // Pass 1 — 지표 계산 (O(1) 증분) ExecuteStmts(_plan.Pass1Stmts); var t2 = sw.ElapsedTicks; // Pass 2 — 상태 머신 (순차) ExecuteStmts(_plan.Pass2Stmts); var t3 = sw.ElapsedTicks; if (inSignalRange) LogBarDetail(c, i + 1, candles.Count); else LogWarmupSnap(c, i + 1, candles.Count); var t4 = sw.ElapsedTicks; swInjectMs += t1 - t0; swPass1Ms += t2 - t1; swPass2Ms += t3 - t2; swLogMs += t4 - t3; } double ticksPerMs = (double)Stopwatch.Frequency / 1000.0; Log.Information("[Executor] 실행 완료 | {Count}봉 처리", candles.Count); Log.Information("═══ Pass별 소요 시간 ═══════════════"); Log.Information(" Inject OHLCV : {Ms,8:F1} ms", swInjectMs / ticksPerMs); Log.Information(" Pass1(지표) : {Ms,8:F1} ms", swPass1Ms / ticksPerMs); Log.Information(" Pass2(상태) : {Ms,8:F1} ms", swPass2Ms / ticksPerMs); Log.Information(" Logging : {Ms,8:F1} ms", swLogMs / ticksPerMs); Log.Information("════════════════════════════════════"); PineEvaluator.DumpStats(); } // ════════════════════════════════════════════════════════════════ // 실시간 모드 (스트리밍) // ════════════════════════════════════════════════════════════════ /// /// 실시간 연결 전, 과거 봉으로 지표 상태를 초기화합니다. /// warmupCandles: 최근 WarmupBars개 봉 (지표 최대 윈도우 이상이어야 함). /// public void WarmUp(List warmupCandles) { ResetState(); EnsurePlan(); Log.Information("[Executor] WarmUp 시작 | {Count}봉", warmupCandles.Count); for (int i = 0; i < warmupCandles.Count; i++) { InjectOhlcv(warmupCandles[i], i); ExecuteStmts(_plan!.Pass1Stmts); ExecuteStmts(_plan.Pass2Stmts); } _realtimeBarIndex = warmupCandles.Count; Log.Information("[Executor] WarmUp 완료 | 다음 bar_index={Idx}", _realtimeBarIndex); } /// /// 실시간 단일 봉 처리. /// WarmUp() 이후에 호출해야 합니다. /// 반환값: 이 봉에서 발생한 신호 목록 (없으면 빈 리스트). /// public List ProcessCandle(CandleData candle) { EnsurePlan(); int prevCount = TradingSignal.Results.Count; InjectOhlcv(candle, _realtimeBarIndex++); ExecuteStmts(_plan!.Pass1Stmts); ExecuteStmts(_plan.Pass2Stmts); // 이번 봉에서 새로 추가된 신호만 반환 return TradingSignal.Results.Count > prevCount ? TradingSignal.Results.GetRange(prevCount, TradingSignal.Results.Count - prevCount) : new List(); } // ════════════════════════════════════════════════════════════════ // 내부 헬퍼 // ════════════════════════════════════════════════════════════════ private void ResetState() { PineContext.Reset(); PineStateVars.Clear(); TradingSignal.ClearResults(); IndicatorEngine.Reset(); _realtimeBarIndex = 0; } private void EnsurePlan() { _program ??= LoadScript(); if (_plan != null) return; _plan = DependencyAnalyzer.Analyze(_program); Log.Information("[Executor] 의존성 분석 완료 | Pass1={P1}문 Pass2={P2}문 | " + "Pass1변수={Vars}개 StateVars={State}개", _plan.Pass1Stmts.Count, _plan.Pass2Stmts.Count, _plan.Pass1Vars.Count, _plan.StateVars.Count); } private static void InjectOhlcv(CandleData c, int barIndex) { Pine.open = (double)c.Open; Pine.high = (double)c.High; Pine.low = (double)c.Low; Pine.close = (double)c.Close; Pine.volume = (double)c.Volume; Pine.time = c.Time.ToUnixTimeMilliseconds(); PineContext.CurrentBarIndex = barIndex; } // ════════════════════════════════════════════════════════════════ // 추적 로그 // ════════════════════════════════════════════════════════════════ /// 분석 구간 매 봉: 핵심 변수 한 줄 + 신호·NearMiss 상세 private static void LogBarDetail(CandleData c, int barNum, int total) { bool debugOn = Log.IsEnabled(LogEventLevel.Debug); // ── ① 신호 발생 여부 최우선 확인 (4개 조회) ───────────── bool sigL = PineEvaluator.ToBool(PineStateVars.Get("newLongSignal")); bool sigLS = PineEvaluator.ToBool(PineStateVars.Get("longSellSignal")); bool sigS = PineEvaluator.ToBool(PineStateVars.Get("newShortSignal")); bool sigSS = PineEvaluator.ToBool(PineStateVars.Get("shortSellSignal")); if (sigL || sigLS || sigS || sigSS) { Log.Information( "★[SIGNAL] {Time} Bar={B}/{T} | " + "NewLong={SL} LongSell={SLS} NewShort={SS} ShortSell={SSS} | " + "Close={C} sma72={S72} cloudTop={CT}", c.Time.ToString("yyyy-MM-dd HH:mm"), barNum, total, sigL, sigLS, sigS, sigSS, FV(c.Close), FV(PineStateVars.Get("sma72")), FV(PineStateVars.Get("cloudTopNow"))); return; } // ── ② rawNew* 확인 (NearMiss) ───────────────────────────── bool rawL = PineEvaluator.ToBool(PineStateVars.Get("rawNewLong")); bool rawS = PineEvaluator.ToBool(PineStateVars.Get("rawNewShort")); // ── ③ NearMiss도 없고 Debug도 꺼져있으면 즉시 리턴 ─────── // → 나머지 10개 Dict 조회·포맷팅 완전 생략 if (!rawL && !rawS && !debugOn) return; // ── ④ 나머지 변수 (필요한 경우에만 조회) ───────────────── bool inL = PineEvaluator.ToBool(PineStateVars.Get("inLong")); bool inS = PineEvaluator.ToBool(PineStateVars.Get("inShort")); bool sidewaysL = PineEvaluator.ToBool(PineStateVars.Get("isSidewaysLong")); bool sidewaysS = PineEvaluator.ToBool(PineStateVars.Get("isSidewaysShort")); bool touchL = PineEvaluator.ToBool(PineStateVars.Get("isLongRecentTouch")); bool touchS = PineEvaluator.ToBool(PineStateVars.Get("isShortRecentTouch")); if (rawL || rawS) { Log.Information( "△[NEAR ] {Time} | rawL={RL} rawS={RS} inL={IL} inS={IS} | " + "sideL={SL} sideS={SS} touchL={TL} touchS={TS}", c.Time.ToString("yyyy-MM-dd HH:mm"), rawL, rawS, inL, inS, sidewaysL, sidewaysS, touchL, touchS); } // ── ⑤ 매 봉 상세: Debug 레벨 (기본 꺼짐 → 스킵) ───────── if (!debugOn) return; string sma72 = FV(PineStateVars.Get("sma72")); string sma4M = FV(PineStateVars.Get("sma4Middle")); string cloudTop = FV(PineStateVars.Get("cloudTopNow")); string rangeTh = FV(PineStateVars.Get("rangeThresholdLongPercent")); bool rawLS = PineEvaluator.ToBool(PineStateVars.Get("rawLongSell")); bool rawSS = PineEvaluator.ToBool(PineStateVars.Get("rawShortSell")); Log.Debug( "[BAR ] {Time} | C={C} s72={S72} s4M={S4M} cTop={CT} rTh={RT} | " + "sideL={SL} sideS={SS} tL={TL} tS={TS} | " + "rawL={RL} rawS={RS} rawLS={RLS} rawSS={RSS} | inL={IL} inS={IS}", c.Time.ToString("yyyy-MM-dd HH:mm"), FV(c.Close), sma72, sma4M, cloudTop, rangeTh, B(sidewaysL), B(sidewaysS), B(touchL), B(touchS), B(rawL), B(rawS), B(rawLS), B(rawSS), B(inL), B(inS)); } /// 워밍업 구간: 50봉마다 지표 준비 상황만 출력 private static void LogWarmupSnap(CandleData c, int barNum, int total) { if (barNum % 50 != 0 && barNum != 1) return; Log.Information( "[WARM] Bar={B}/{T} {Time} | close={C} sma72={S72} cloudTop={CT} rangeTh={RT}", barNum, total, c.Time.ToString("yyyy-MM-dd HH:mm"), FV(c.Close), FV(PineStateVars.Get("sma72")), FV(PineStateVars.Get("cloudTopNow")), FV(PineStateVars.Get("rangeThresholdLongPercent"))); } // ── 포맷 헬퍼 ──────────────────────────────────────────────────── private static string FV(object? v) { if (v == null) return "na"; if (v is double d) return d.ToString("F4"); if (v is decimal m) return m.ToString("F4"); return v.ToString() ?? "na"; } private static string FV(decimal m) => m.ToString("F4"); private static string B(bool b) => b ? "T" : "F"; // ── 스크립트 로드 & 파싱 ────────────────────────────────────── private List LoadScript() { if (!File.Exists(_filePath)) { Log.Error("[Executor] 파일 없음: {Path}", _filePath); return new List(); } // 들여쓰기 보존 → (indent, content) 쌍으로 수집 var lines = new List<(int Indent, string Raw)>(); foreach (string raw in File.ReadAllLines(_filePath)) { string trimEnd = raw.TrimEnd(); if (string.IsNullOrWhiteSpace(trimEnd)) continue; string content = trimEnd.TrimStart(); // 주석·버전 어노테이션 제외 if (content.StartsWith("//") || content.StartsWith("//@")) continue; int indent = trimEnd.Length - content.Length; lines.Add((indent, content)); } Log.Information("[Executor] 스크립트 로드 | 유효 라인 {Count}개 | {Path}", lines.Count, _filePath); int pos = 0; var stmts = BuildBlock(lines, ref pos, -1); Log.Information("[Executor] 문(Statement) 파싱 완료 | {Count}개", stmts.Count); return stmts; } // ── 블록 빌더 (재귀) ───────────────────────────────────────── /// /// parentIndent 보다 큰 들여쓰기 수준의 라인들을 문 목록으로 변환합니다. /// private List BuildBlock(List<(int Indent, string Raw)> lines, ref int pos, int parentIndent) { var stmts = new List(); while (pos < lines.Count) { var (indent, raw) = lines[pos]; // 상위 블록으로 복귀 if (indent <= parentIndent) break; // ── if 처리 ── if (raw.StartsWith("if ", StringComparison.OrdinalIgnoreCase) || raw.StartsWith("if(", StringComparison.OrdinalIgnoreCase)) { string condition = ExtractCondition(raw); int myIndent = indent; pos++; // then 블록: 다음 라인들 중 indent > myIndent var thenBody = pos < lines.Count && lines[pos].Indent > myIndent ? BuildBlock(lines, ref pos, myIndent) : new List(); // else 블록 List? elseBody = null; if (pos < lines.Count && lines[pos].Indent == myIndent && lines[pos].Raw.Equals("else", StringComparison.OrdinalIgnoreCase)) { pos++; elseBody = pos < lines.Count && lines[pos].Indent > myIndent ? BuildBlock(lines, ref pos, myIndent) : new List(); } stmts.Add(new IfStmt(condition, thenBody, elseBody)); continue; // pos는 BuildBlock 내부에서 이미 이동 } // ── 일반 문 ── var stmt = ParseStatement(raw); if (stmt != null) stmts.Add(stmt); pos++; } return stmts; } // ── 문 파싱 ─────────────────────────────────────────────────── private static PineStmt? ParseStatement(string line) { // 1) var 선언: var bool inLong = false if (line.StartsWith("var ", StringComparison.OrdinalIgnoreCase)) { string rest = line.Substring(4).Trim(); // type hint 제거 string typeHint = ""; string[] types = { "float[]", "int[]", "bool[]", "string[]", "bool", "int", "float", "string" }; foreach (var t in types) { if (rest.StartsWith(t + " ", StringComparison.OrdinalIgnoreCase)) { typeHint = t; rest = rest.Substring(t.Length).Trim(); break; } } int eqIdx = FindAssignEq(rest); if (eqIdx > 0) { string name = rest.Substring(0, eqIdx).Trim(); string expr = rest.Substring(eqIdx + 1).Trim(); return new VarDeclStmt(name, typeHint, expr); } // var foo (초기화 없음) string justName = rest.Split(' ')[0].Trim(); return new VarDeclStmt(justName, typeHint, "na"); } // 2) := 재할당: inLong := true { int cIdx = FindReassignOp(line); if (cIdx > 0) { string name = line.Substring(0, cIdx).Trim(); string expr = line.Substring(cIdx + 2).Trim(); if (IsSimpleIdent(name)) return new ReassignStmt(name, expr); } } // 3) 대입: sma4 = expr { int eqIdx = FindAssignEq(line); if (eqIdx > 0) { string lhs = line.Substring(0, eqIdx).Trim(); string rhs = line.Substring(eqIdx + 1).Trim(); if (IsSimpleIdent(lhs) && !string.IsNullOrEmpty(rhs)) return new AssignStmt(lhs, rhs); } } // 4) 표현식 문: plotshape(...), plot(...) 등 if (!string.IsNullOrEmpty(line)) return new ExprStmt(line); return null; } // ── 문 실행 ─────────────────────────────────────────────────── private static void ExecuteStmts(List stmts) { foreach (var stmt in stmts) ExecuteOne(stmt); } private static void ExecuteOne(PineStmt stmt) { switch (stmt) { // ── var 선언 (최초 1회만 초기화) ── case VarDeclStmt vd: { if (!PineStateVars.Has(vd.Name)) { object? val = PineEvaluator.Evaluate(vd.Expr); PineStateVars.Declare(vd.Name, val); Log.Debug("[Exec] var {Name} = {Val}", vd.Name, val); } break; } // ── 일반 대입 (매 봉마다 재평가) ── case AssignStmt a: { object? val = PineEvaluator.Evaluate(a.Expr); PineStateVars.Set(a.Name, val); // double 값은 PineContext 시리즈에도 Push (ta.sma 등이 읽음) double dv = PineEvaluator.ToDouble(val); if (!double.IsNaN(dv)) PineContext.SetVariable(a.Name, dv); Log.Debug("[Exec] {Name} = {Val}", a.Name, val); break; } // ── := 재할당 ── case ReassignStmt r: { object? val = PineEvaluator.Evaluate(r.Expr); PineStateVars.Set(r.Name, val); double dv = PineEvaluator.ToDouble(val); if (!double.IsNaN(dv)) PineContext.SetVariable(r.Name, dv); Log.Debug("[Exec] {Name} := {Val}", r.Name, val); break; } // ── 표현식 문 (side-effect 목적) ── case ExprStmt e: { PineEvaluator.Evaluate(e.Expr); break; } // ── if / else ── case IfStmt ifs: { bool cond = PineEvaluator.EvaluateBool(ifs.Condition); Log.Debug("[Exec] if ({Cond}) → {Result}", ifs.Condition, cond); if (cond) ExecuteStmts(ifs.ThenBody); else if (ifs.ElseBody != null) ExecuteStmts(ifs.ElseBody); break; } } } // ════════════════════════════════════════════════════════════════ // 파싱 헬퍼 // ════════════════════════════════════════════════════════════════ /// if 라인에서 조건 문자열 추출 private static string ExtractCondition(string line) { // "if " or "if(" 이후 텍스트 string rest = line.Length > 3 ? line.Substring(3).Trim() : ""; // 바깥 괄호 제거: "(cond)" → "cond" if (rest.StartsWith("(") && rest.EndsWith(")")) { // 매칭 괄호 확인 int depth = 0; bool matched = false; for (int i = 0; i < rest.Length; i++) { if (rest[i] == '(') depth++; else if (rest[i] == ')') { depth--; if (depth == 0 && i == rest.Length - 1) { matched = true; break; } } } if (matched) rest = rest.Substring(1, rest.Length - 2).Trim(); } return rest; } /// /// 순수 대입 '=' 위치 반환. /// ==, !=, <=, >=, := 는 제외. /// 괄호/대괄호 안은 탐색 제외. /// private static int FindAssignEq(string line) { int depth = 0; for (int i = 1; i < line.Length; i++) { char c = line[i]; if (c == '(' || c == '[') { depth++; continue; } if (c == ')' || c == ']') { depth--; continue; } if (depth > 0) continue; if (c == '=') { char prev = line[i - 1]; char next = i + 1 < line.Length ? line[i + 1] : '\0'; bool compound = prev is '!' or '<' or '>' or '=' or ':'; if (!compound && next != '=') return i; } } return -1; } /// := 위치 반환 (없으면 -1), 괄호 밖만 탐색 private static int FindReassignOp(string line) { int depth = 0; for (int i = 0; i < line.Length - 1; i++) { char c = line[i]; if (c == '(' || c == '[') { depth++; continue; } if (c == ')' || c == ']') { depth--; continue; } if (depth > 0) continue; if (c == ':' && line[i + 1] == '=') return i; } return -1; } /// 단순 변수명 여부 (영문자·숫자·밑줄만, 첫 글자는 영문자/밑줄) private static bool IsSimpleIdent(string s) { if (string.IsNullOrEmpty(s)) return false; if (!char.IsLetter(s[0]) && s[0] != '_') return false; foreach (char c in s) if (!char.IsLetterOrDigit(c) && c != '_') return false; return true; } } }