Files
Bithumb/deepcoin/matching/rule_eval.py
dsyoon 2cb67c42b3 GT MTF 프로필·캘리브레이션과 04 매칭/시뮬/실거래 파이프라인을 추가한다.
3분~일봉 GT 타점 분석(03c), leg 체결 순서 수정, 총자산 90% 검증 루프,
walk-forward Go/No-Go 시뮬, monitor·live_trader 및 reference 문서를 포함한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 11:27:50 +09:00

319 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
규칙 조건 벡터 평가·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)