""" 규칙 조건 벡터 평가·MTF 스캔 프레임 병합. """ from __future__ import annotations from typing import Any import numpy as np import pandas as pd from config import GENERAL_ANALYSIS_INTERVALS, MATCH_PRIMARY_INTERVAL from deepcoin.analysis.general_analysis_core import interval_tf_prefix from deepcoin.analysis.general_analysis_pipeline import general_analysis_enrich_bars from config import ( ALIGN_RSI_CONFLICT_TIMING_HIGH, ALIGN_RSI_CONFLICT_TIMING_LOW, ALIGN_RSI_CONFLICT_TREND_HIGH, ALIGN_RSI_CONFLICT_TREND_LOW, ALIGN_RSI_OVERBOUGHT, ALIGN_RSI_OVERSOLD, TIMING_INTERVALS, TREND_INTERVALS, ) from deepcoin.analysis.general_analysis_core import ga_col def _add_align_columns_vectorized(frame: pd.DataFrame) -> pd.DataFrame: """ 스캔 프레임에 ga_align_* 컬럼을 벡터 연산으로 추가합니다. Args: frame: TF 접두사 컬럼이 포함된 DataFrame. Returns: align 컬럼이 추가된 DataFrame. """ out = frame.copy() rsi_oversold = pd.Series(0, index=out.index, dtype=float) rsi_overbought = pd.Series(0, index=out.index, dtype=float) n_timing = 0 for iv in TIMING_INTERVALS: p = interval_tf_prefix(iv) rk = f"{p}_RSI" if rk not in out.columns: continue n_timing += 1 rsi = pd.to_numeric(out[rk], errors="coerce") rsi_oversold += (rsi < ALIGN_RSI_OVERSOLD).astype(int) rsi_overbought += (rsi > ALIGN_RSI_OVERBOUGHT).astype(int) trend_up = pd.Series(0, index=out.index, dtype=float) trend_down = pd.Series(0, index=out.index, dtype=float) n_trend = 0 for iv in TREND_INTERVALS: p = interval_tf_prefix(iv) sk = f"{p}_{ga_col('struct_trend')}" if sk not in out.columns: continue n_trend += 1 st = out[sk].astype(str) trend_up += (st == "up").astype(int) trend_down += (st == "down").astype(int) denom_t = max(n_timing, 1) denom_r = max(n_trend, 1) out["ga_align_rsi_oversold_tf"] = rsi_oversold out["ga_align_rsi_overbought_tf"] = rsi_overbought out["ga_align_trend_up_tf"] = trend_up out["ga_align_trend_down_tf"] = trend_down out["ga_align_timing_buy_score"] = (rsi_oversold / denom_t).round(3) out["ga_align_timing_sell_score"] = (rsi_overbought / denom_t).round(3) out["ga_align_trend_score"] = ((trend_up - trend_down) / denom_r).round(3) conflict = pd.Series(0, index=out.index, dtype=int) m3_rsi = out.get("m3_RSI") d1_rsi = out.get("d1_RSI") if m3_rsi is not None and d1_rsi is not None: m3v = pd.to_numeric(m3_rsi, errors="coerce") d1v = pd.to_numeric(d1_rsi, errors="coerce") conflict = ( ((m3v < ALIGN_RSI_CONFLICT_TIMING_LOW) & (d1v > ALIGN_RSI_CONFLICT_TREND_HIGH)) | ((m3v > ALIGN_RSI_CONFLICT_TIMING_HIGH) & (d1v < ALIGN_RSI_CONFLICT_TREND_LOW)) ).astype(int) out["ga_align_mtf_conflict"] = conflict return out def _scalar_float(val: Any) -> float: """Series/ndarray 스칼라를 float로 변환.""" if isinstance(val, pd.Series): val = val.iloc[0] return float(val) def conditions_columns(rules: list[dict[str, Any]]) -> set[str]: """ 규칙 목록에서 참조하는 컬럼명 집합을 반환합니다. Args: rules: rule_candidates 항목 리스트. Returns: 컬럼명 set. """ cols: set[str] = set() for rule in rules: for cond in rule.get("conditions", []): c = cond.get("col") if c: cols.add(c) return cols def build_mtf_scan_frame( frames: dict[int, pd.DataFrame], needed_cols: set[str], ) -> pd.DataFrame: """ 주간격(m3) 인덱스에 필요 컬럼만 merge_asof로 붙인 스캔용 DataFrame을 만듭니다. Args: frames: interval → OHLCV. needed_cols: 규칙 평가에 필요한 컬럼명. Returns: m3 인덱스 wide DataFrame (close 포함). """ primary = MATCH_PRIMARY_INTERVAL raw = frames.get(primary) if raw is None or raw.empty: raise RuntimeError(f"주간격 {primary}분 데이터 없음") print(f"[04b] Phase A: 8TF enrich (스캔용)...") enriched: dict[int, pd.DataFrame] = {} for iv in GENERAL_ANALYSIS_INTERVALS: r = frames.get(iv) if r is None or r.empty: continue label = interval_tf_prefix(iv) print(f" enrich {label} ({len(r):,}봉)...") enriched[iv] = general_analysis_enrich_bars(r, iv, full_context=True) base = enriched[primary].copy() if not isinstance(base.index, pd.DatetimeIndex): base.index = pd.to_datetime(base.index) base = base.sort_index() out = pd.DataFrame(index=base.index) close_col = "close" if "close" in base.columns else "Close" out["close"] = base[close_col].astype(float) def _source_col(prefixed: str, prefix: str, ef: pd.DataFrame) -> str | None: """m3_RSI → RSI, m60_ga_struct_trend → ga_struct_trend.""" if not prefixed.startswith(f"{prefix}_"): return None suffix = prefixed[len(prefix) + 1 :] if suffix in ef.columns: return suffix return None for iv in GENERAL_ANALYSIS_INTERVALS: ef = enriched.get(iv) if ef is None: continue p = interval_tf_prefix(iv) for col in needed_cols: if col in out.columns or not col.startswith(f"{p}_"): continue src = _source_col(col, p, ef) if src is None: continue if iv == primary: out[col] = ef[src].reindex(out.index) else: sub = ef[[src]].copy() if not isinstance(sub.index, pd.DatetimeIndex): sub.index = pd.to_datetime(sub.index) sub = sub.sort_index().rename(columns={src: col}) merged = pd.merge_asof( out.reset_index(names="_ts"), sub.reset_index(names="_ts"), on="_ts", direction="backward", ).set_index("_ts") out[col] = merged[col].values align_needed = [c for c in needed_cols if c.startswith("ga_align_")] if align_needed: out = _add_align_columns_vectorized(out) out = out.loc[:, ~out.columns.duplicated()] out = out.dropna(subset=["close"]) print(f"[04b] 스캔 프레임: {len(out):,}봉 × {len(out.columns)}열") return out def _eval_one_condition( frame: pd.DataFrame, cond: dict[str, Any], ) -> pd.Series: """ 단일 조건 boolean Series. Args: frame: 평가 대상. cond: {col, op, ...}. Returns: boolean Series. """ col = cond.get("col") if not col or col not in frame.columns: return pd.Series(False, index=frame.index) s = frame[col] op = cond.get("op", "between") if op == "between": lo, hi = float(cond["lo"]), float(cond["hi"]) ok = pd.to_numeric(s, errors="coerce") part = (ok >= lo) & (ok <= hi) elif op == "gte": part = pd.to_numeric(s, errors="coerce") >= float(cond["value"]) elif op == "lte": part = pd.to_numeric(s, errors="coerce") <= float(cond["value"]) elif op == "eq": val = cond["value"] if isinstance(val, (int, float)) and pd.api.types.is_numeric_dtype(s): part = pd.to_numeric(s, errors="coerce") == float(val) else: part = s.astype(str) == str(val) elif op == "eq_int": part = ( pd.to_numeric(s, errors="coerce").fillna(-999).astype(int) == int(cond["value"]) ) else: part = pd.Series(False, index=frame.index) return part.fillna(False) def eval_conditions(frame: pd.DataFrame, conditions: list[dict[str, Any]]) -> pd.Series: """ 단일 규칙의 조건을 모두 AND로 평가합니다. Args: frame: 스캔용 DataFrame. conditions: {col, op, ...} 리스트. Returns: boolean Series (인덱스=frame.index). """ mask = pd.Series(True, index=frame.index) for cond in conditions: mask &= _eval_one_condition(frame, cond) return mask def eval_rule_mask(frame: pd.DataFrame, rule: dict[str, Any]) -> pd.Series: """ 규칙 dict 평가 (logic=and|or). Args: frame: 스캔/스냅샷 DataFrame. rule: conditions, logic 키 포함. Returns: boolean Series. """ conditions = rule.get("conditions") or [] if not conditions: return pd.Series(False, index=frame.index) logic = str(rule.get("logic", "and")).lower() if logic == "or": mask = pd.Series(False, index=frame.index) for cond in conditions: mask |= _eval_one_condition(frame, cond) return mask return eval_conditions(frame, conditions) def scan_rule_fires( frame: pd.DataFrame, rules: list[dict[str, Any]], ) -> pd.DataFrame: """ 모든 규칙 후보에 대해 발화 시각을 수집합니다. Args: frame: build_mtf_scan_frame 결과. rules: rule_candidates. Returns: fire_id, rule_id, side, dt, close 컬럼 DataFrame. """ rows: list[dict[str, Any]] = [] fid = 0 for rule in rules: rid = rule["rule_id"] side = rule["side"] mask = eval_rule_mask(frame, rule) hits = frame.index[mask] close_s = frame["close"] if isinstance(close_s, pd.DataFrame): close_s = close_s.iloc[:, 0] for ts in hits: rows.append( { "fire_id": fid, "rule_id": rid, "side": side, "dt": ts.strftime("%Y-%m-%d %H:%M:%S"), "close": _scalar_float(close_s.loc[ts]), } ) fid += 1 print(f" 규칙 {rid}: 발화 {len(hits):,}건") if not rows: return pd.DataFrame(columns=["fire_id", "rule_id", "side", "dt", "close"]) return pd.DataFrame(rows)