""" Phase 3: monitor 발화 + drawdown/past-leg tier (인과적). 매도는 monitor(sell_mtf_cross) 유지, tier만 drawdown·과거 leg 수익으로 강화합니다. """ from __future__ import annotations from typing import Any import pandas as pd from config import ( CAUSAL_GT_DD_LARGE_PCT, CAUSAL_GT_DD_MEDIUM_PCT, GT_BUY_PCT_LARGE_LEG, GT_BUY_PCT_MEDIUM_LEG, GT_BUY_PCT_SMALL_LEG, GT_INITIAL_CASH_KRW, SIM_TIER_CONVICTION_DD_PCT, TRADING_FEE_RATE, ) from deepcoin.ground_truth.gt_allocation import ( allocate_order_amounts_chronological, simulate_portfolio_summary, ) from deepcoin.matching.portfolio_sim import sort_fires_chronological from deepcoin.matching.position_sizing import enrich_sim_trades_with_gt_weights def _deduped_ohlc(df: pd.DataFrame) -> pd.DataFrame: """ DatetimeIndex 중복 제거·정렬 (drawdown lookup용). Args: df: OHLC DataFrame. Returns: index unique OHLC. """ if df.empty: return df out = df.sort_index() if not out.index.is_unique: out = out[~out.index.duplicated(keep="last")] return out def _close_series_from_df(df: pd.DataFrame) -> pd.Series: """ OHLC DataFrame에서 종가 시리즈 추출 (positional index). Args: df: Open/Close 또는 open/close 컬럼을 가진 OHLC. Returns: float 종가 시리즈. """ if df.empty: return pd.Series(dtype=float) frame = _deduped_ohlc(df) for col in ("close", "Close"): if col in frame.columns: return frame[col].astype(float).reset_index(drop=True) raise KeyError("OHLC DataFrame에 close/Close 컬럼이 없습니다.") def _bar_index_at(df: pd.DataFrame, dt: str) -> int: """ 시각 dt에 대응하는 bar 위치 (인덱스 중복 시 nearest). Args: df: DatetimeIndex OHLC. dt: ISO 시각 문자열. Returns: 정수 bar 위치 (0..n-1). """ frame = _deduped_ohlc(df) if frame.empty: return 0 try: ts = pd.to_datetime(dt) except (TypeError, ValueError): return 0 pos = int(frame.index.get_indexer([ts], method="nearest")[0]) return max(pos, 0) def _drawdown_pct_at_index(closes: pd.Series, idx: int) -> float: """ bar idx 시점 drawdown % (과거 rolling high 대비, 인과적). Args: closes: 종가 시리즈. idx: 봉 위치. Returns: drawdown % (0~100). """ if idx < 0 or idx >= len(closes): return 0.0 seg = closes.iloc[: idx + 1].astype(float) if seg.empty: return 0.0 peak = float(seg.max()) cur = float(seg.iloc[-1]) if peak <= 0: return 0.0 return max((peak - cur) / peak * 100.0, 0.0) def hybrid_tier_scale( trade: dict[str, Any], *, completed_leg_ret: dict[int, float], enhanced: bool = False, dd_large_pct: float | None = None, dd_medium_pct: float | None = None, ) -> float: """ 과거 leg 수익 tier + drawdown tier (인과적). Args: trade: 매수 trade dict (drawdown_pct 포함). completed_leg_ret: 청산 완료 leg realized return %. enhanced: True면 medium tier·conviction 플래그 적용. dd_large_pct: drawdown large tier 임계(%). None이면 config. dd_medium_pct: drawdown medium tier 임계(%). None이면 config. Returns: asset_pct_scale. """ from config import GT_LARGE_LEG_TOP_PCT from deepcoin.matching.position_sizing import ( large_leg_ids_from_past_returns, ) dd_large = float(dd_large_pct if dd_large_pct is not None else CAUSAL_GT_DD_LARGE_PCT) dd_medium = float(dd_medium_pct if dd_medium_pct is not None else CAUSAL_GT_DD_MEDIUM_PCT) lid = int(trade.get("leg_id", 0)) large_past = large_leg_ids_from_past_returns(completed_leg_ret, GT_LARGE_LEG_TOP_PCT) dd = float(trade.get("drawdown_pct") or 0.0) if lid in large_past: if enhanced and dd >= SIM_TIER_CONVICTION_DD_PCT: trade["conviction_buy"] = True return float(GT_BUY_PCT_LARGE_LEG) if dd >= dd_large: if enhanced: trade["conviction_buy"] = True return float(GT_BUY_PCT_LARGE_LEG) if dd >= dd_medium: if enhanced and dd >= SIM_TIER_CONVICTION_DD_PCT: trade["conviction_buy"] = True return float(GT_BUY_PCT_MEDIUM_LEG) if enhanced else float(GT_BUY_PCT_LARGE_LEG) * 0.5 return float(GT_BUY_PCT_SMALL_LEG) def _monitor_rows_from_fires(fires: pd.DataFrame) -> list[dict[str, Any]]: """monitor 발화 DataFrame → trade dict 리스트.""" rows: list[dict[str, Any]] = [] for _, r in sort_fires_chronological(fires).iterrows(): rows.append( { "dt": str(r["dt"]), "action": r["side"], "price": float(r["close"]), "rule_id": r.get("rule_id", ""), } ) return rows def build_monitor_hybrid_sized_trades( fires: pd.DataFrame, df: pd.DataFrame, *, enhanced: bool = False, initial_cash: float = GT_INITIAL_CASH_KRW, fee_rate: float = TRADING_FEE_RATE, dd_large_pct: float | None = None, dd_medium_pct: float | None = None, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: """ monitor 발화 → hybrid tier amount_krw 배분 (인과적). Args: fires: monitor rule 발화 (buy+sell). df: 3m OHLC (drawdown 계산). enhanced: conviction·medium tier 사용. initial_cash: 시작 현금. fee_rate: 수수료율. Returns: (amount_krw가 채워진 trade dict, alloc_stats). """ from deepcoin.ground_truth.ground_truth import load_ground_truth, order_trades_chronological from deepcoin.paths import resolve_ground_truth_file if fires.empty: return [], {"buy_executed": 0, "buy_skipped": 0} gt_data = load_ground_truth(resolve_ground_truth_file()) or {} gt_trades = order_trades_chronological(gt_data.get("trades") or []) enriched = enrich_sim_trades_with_gt_weights( _monitor_rows_from_fires(fires), gt_trades, causal_legs=True, ) enriched = _attach_drawdown_to_buys(enriched, df) def scale_fn(t: dict[str, Any], completed_leg_ret: dict[int, float]) -> float: return hybrid_tier_scale( t, completed_leg_ret=completed_leg_ret, enhanced=enhanced, dd_large_pct=dd_large_pct, dd_medium_pct=dd_medium_pct, ) return allocate_order_amounts_chronological( enriched, initial_cash=initial_cash, fee_rate=fee_rate, causal_tier=False, asset_pct_scale_fn=scale_fn, ) def _simulate_monitor_tier_portfolio( fires: pd.DataFrame, df: pd.DataFrame, *, enhanced: bool = False, last_price: float | None = None, initial_cash: float = GT_INITIAL_CASH_KRW, fee_rate: float = TRADING_FEE_RATE, dd_large_pct: float | None = None, dd_medium_pct: float | None = None, ) -> dict[str, Any]: """ monitor buy+sell + tier 복리 시뮬 (hybrid 또는 enhanced). Args: fires: monitor rule 발화 (buy+sell). df: 3m OHLC (drawdown 계산). enhanced: conviction·medium tier 사용. last_price: 미청산 평가가. initial_cash: 시작 현금. fee_rate: 수수료율. dd_large_pct: drawdown large tier 임계(%). dd_medium_pct: drawdown medium tier 임계(%). Returns: portfolio summary dict. """ mode = "monitor_tier_enhanced" if enhanced else "monitor_dd_tier" if fires.empty: return {"pnl_pct": 0.0, "trade_count": 0, "sizing_mode": mode} sized, alloc_stats = build_monitor_hybrid_sized_trades( fires, df, enhanced=enhanced, initial_cash=initial_cash, fee_rate=fee_rate, dd_large_pct=dd_large_pct, dd_medium_pct=dd_medium_pct, ) mark = last_price if mark is None and not df.empty: try: mark = float(_close_series_from_df(df).iloc[-1]) except KeyError: mark = None result = simulate_portfolio_summary( sized, initial_cash=initial_cash, fee_rate=fee_rate, last_price=mark, use_amount_krw=True, ) result["sizing_mode"] = mode if enhanced: result["sizing_note"] = ( "monitor buy+sell + past-leg·drawdown tier + conviction (미래 미사용)" ) else: result["sizing_note"] = ( "monitor buy+sell + drawdown·past-leg tier (미래 미사용)" ) result["alloc_stats"] = alloc_stats result["input_fires"] = int(len(fires)) return result def _attach_drawdown_to_buys( trades: list[dict[str, Any]], df: pd.DataFrame, ) -> list[dict[str, Any]]: """ 매수 trade에 bar drawdown % 부여 (인과적). Args: trades: enrich된 trade dict. df: 3m OHLC (DatetimeIndex). Returns: drawdown_pct가 추가된 trade dict. """ if df.empty: return trades close_s = _close_series_from_df(df) out: list[dict[str, Any]] = [] for t in trades: row = dict(t) if row.get("action") != "buy": out.append(row) continue bar_idx = _bar_index_at(df, str(row.get("dt", ""))) row["drawdown_pct"] = round(_drawdown_pct_at_index(close_s, bar_idx), 2) out.append(row) return out def simulate_monitor_dd_tier_portfolio( fires: pd.DataFrame, df: pd.DataFrame, *, last_price: float | None = None, initial_cash: float = GT_INITIAL_CASH_KRW, fee_rate: float = TRADING_FEE_RATE, dd_large_pct: float | None = None, dd_medium_pct: float | None = None, ) -> dict[str, Any]: """ monitor buy+sell + drawdown/past-leg tier 복리 시뮬. Args: fires: monitor rule 발화 (buy+sell). df: 3m OHLC (drawdown 계산). last_price: 미청산 평가가. initial_cash: 시작 현금. fee_rate: 수수료율. dd_large_pct: drawdown large tier 임계(%). dd_medium_pct: drawdown medium tier 임계(%). Returns: portfolio summary dict. """ return _simulate_monitor_tier_portfolio( fires, df, enhanced=False, last_price=last_price, initial_cash=initial_cash, fee_rate=fee_rate, dd_large_pct=dd_large_pct, dd_medium_pct=dd_medium_pct, ) def simulate_monitor_tier_enhanced_portfolio( fires: pd.DataFrame, df: pd.DataFrame, *, last_price: float | None = None, initial_cash: float = GT_INITIAL_CASH_KRW, fee_rate: float = TRADING_FEE_RATE, ) -> dict[str, Any]: """ Phase 4: monitor + past-leg·drawdown tier + conviction (weight 분할 생략). Args: fires: monitor rule 발화 (buy+sell). df: 3m OHLC (drawdown 계산). last_price: 미청산 평가가. initial_cash: 시작 현금. fee_rate: 수수료율. Returns: portfolio summary dict. """ return _simulate_monitor_tier_portfolio( fires, df, enhanced=True, last_price=last_price, initial_cash=initial_cash, fee_rate=fee_rate, ) def simulate_causal_gt_hybrid_portfolio( buy_fires: pd.DataFrame, df: pd.DataFrame, *, monitor_fires: pd.DataFrame | None = None, last_price: float | None = None, cg_params: dict[str, Any] | None = None, initial_cash: float = GT_INITIAL_CASH_KRW, fee_rate: float = TRADING_FEE_RATE, dd_large_pct: float | None = None, dd_medium_pct: float | None = None, ) -> dict[str, Any]: """ Phase 3 하이브리드: monitor buy+sell + DD tier (권장). monitor_fires가 있으면 DD tier 경로, 없으면 구 peak-sell 경로(legacy). Args: buy_fires: buy 발화 (legacy peak-sell 경로용). df: 3m OHLCV. monitor_fires: monitor buy+sell (권장). last_price: 미청산 평가가. cg_params: legacy 파라미터. initial_cash: 시작 현금. fee_rate: 수수료율. dd_large_pct: drawdown large tier 임계(%). dd_medium_pct: drawdown medium tier 임계(%). Returns: portfolio summary dict. """ if monitor_fires is not None and not monitor_fires.empty: return simulate_monitor_dd_tier_portfolio( monitor_fires, df, last_price=last_price, initial_cash=initial_cash, fee_rate=fee_rate, dd_large_pct=dd_large_pct, dd_medium_pct=dd_medium_pct, ) return { "pnl_pct": 0.0, "trade_count": 0, "note": "monitor_fires required", "sizing_mode": "causal_gt_hybrid", }