""" 3단계: monitor_rules 발화 시 빗썸 실주문 (가드·로그). """ from __future__ import annotations import json import time from datetime import date, datetime from pathlib import Path from typing import Any from config import ( CHART_LOOKBACK_DAYS, COIN_NAME, LIVE_COOLDOWN_MIN, LIVE_DAILY_KRW_MAX, LIVE_DAILY_LOSS_LIMIT_KRW, LIVE_MAX_TRADES_PER_DAY, LIVE_ORDER_KRW, LIVE_TRADING_ENABLED, MATCH_PRIMARY_INTERVAL, SYMBOL, TRADING_FEE_RATE, ) from deepcoin.data.mtf_bb import load_frames_from_db from deepcoin.ground_truth.ground_truth import load_ground_truth from deepcoin.matching.live_eval import evaluate_live_rules from deepcoin.matching.live_sizing import LivePositionState, live_sizing_enabled from deepcoin.matching.load_rules import load_monitor_rules from deepcoin.matching.position_sizing import ( compute_buy_amount_krw, live_buy_asset_pct_scale, load_ev_wf_approved_rule_ids, top_leg_ids_by_forward_return, ) from deepcoin.paths import resolve_ground_truth_file from deepcoin.ops.alert_message import build_rule_alert_message from deepcoin.ops.monitor import Monitor from deepcoin.paths import LIVE_TRADES_LOG, PAPER_FIRES_LOG class LiveTrader(Monitor): """ 규칙 발화 시 실거래 실행. LIVE_TRADING_ENABLED=0 이면 주문 없음(드라이런 로그만). """ def __init__(self) -> None: """Monitor 초기화, 일별 카운터 비움.""" super().__init__(cooldown_file=None) self._rule_last_unix: dict[str, float] = {} self._day: str = "" self._day_spent_krw: float = 0.0 self._day_trades: int = 0 self._day_pnl_krw: float = 0.0 self._gt_trades: list[dict] = [] self._large_legs: set[int] = set() self._approved_rules: set[str] = set() self._position_state = LivePositionState.load() self._ohlc_df = None self._load_sizing_context() def _load_sizing_context(self) -> None: """GT leg·EV/WF 통과 규칙 캐시.""" gt = load_ground_truth(resolve_ground_truth_file()) or {} self._gt_trades = gt.get("trades") or [] self._large_legs = top_leg_ids_by_forward_return(self._gt_trades) self._approved_rules = load_ev_wf_approved_rule_ids() def _reset_day_if_needed(self) -> None: """날짜 변경 시 일별 한도 카운터 초기화.""" today = date.today().isoformat() if today != self._day: self._day = today self._day_spent_krw = 0.0 self._day_trades = 0 self._day_pnl_krw = 0.0 def _append_log(self, record: dict[str, Any]) -> None: """ live_trades.jsonl에 한 줄 append. Args: record: 로그 dict. """ LIVE_TRADES_LOG.parent.mkdir(parents=True, exist_ok=True) with LIVE_TRADES_LOG.open("a", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n") def _append_paper_fire( self, hit: dict[str, Any], planned_krw: float, would_trade: bool, skip_reason: str = "", order_log: dict[str, Any] | None = None, ) -> None: """ Phase C dry-run: 모든 발화·스킵 사유·모의 금액을 paper_fires.jsonl에 기록. 금요일 `07_phase_c_paper_report.py`로 forward 수익률(참고) 집계. """ PAPER_FIRES_LOG.parent.mkdir(parents=True, exist_ok=True) row = { "ts": datetime.now().isoformat(timespec="seconds"), "signal_dt": hit.get("dt"), "rule_id": hit.get("rule_id"), "side": hit.get("side"), "close": float(hit.get("close") or 0), "planned_krw": round(float(planned_krw), 0), "would_trade": bool(would_trade), "skip_reason": skip_reason or "", "live_enabled": bool(LIVE_TRADING_ENABLED), "order_message": (order_log or {}).get("message", ""), } with PAPER_FIRES_LOG.open("a", encoding="utf-8") as f: f.write(json.dumps(row, ensure_ascii=False) + "\n") def _can_trade(self, rule_id: str, planned_krw: float | None = None) -> tuple[bool, str]: """ 일·쿨다운·손실 한도 검사. Args: rule_id: 규칙 ID. Returns: (허용 여부, 사유). """ self._reset_day_if_needed() if self._day_trades >= LIVE_MAX_TRADES_PER_DAY: return False, "일 최대 거래 수 초과" need = float(planned_krw if planned_krw is not None else LIVE_ORDER_KRW) if self._day_spent_krw + need > LIVE_DAILY_KRW_MAX: return False, "일 주문 한도 초과" if self._day_pnl_krw <= -abs(LIVE_DAILY_LOSS_LIMIT_KRW): return False, "일 손실 한도 초과" last = self._rule_last_unix.get(rule_id, 0.0) if time.time() - last < LIVE_COOLDOWN_MIN * 60: return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)" return True, "" def _load_ohlc_df(self) -> None: """drawdown tier용 3m OHLC 캐시.""" try: frames = load_frames_from_db(self, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS) self._ohlc_df = frames.get(MATCH_PRIMARY_INTERVAL) except Exception: self._ohlc_df = None def _resolve_buy_amount_krw(self, hit: dict[str, Any]) -> float: """ 총자산·현금·EV/WF·leg 티어로 매수 원화 산출. GT_SIGNAL_CAUSAL=1 이면 시뮬 sim_primary(hybrid, enhanced=False)와 동일 tier·weight. Args: hit: evaluate_live_rules 항목. Returns: 매수 원화. """ rid = hit["rule_id"] if rid not in self._approved_rules: return 0.0 price = float(hit["close"]) cash = 0.0 qty = 0.0 try: bal = self.load_balances_dict() sym = bal.get(SYMBOL, {}) cash = float(sym.get("available_krw") or sym.get("krw") or 0) qty = float(sym.get("balance") or 0) except Exception: return 0.0 if live_sizing_enabled(): if self._ohlc_df is None: self._load_ohlc_df() return self._position_state.plan_buy_amount_krw( hit["dt"], price, cash, qty, self._ohlc_df, enhanced=False, fee_rate=TRADING_FEE_RATE, ) scale = live_buy_asset_pct_scale( rid, hit["dt"], self._gt_trades, approved_rules=self._approved_rules, large_legs=self._large_legs, ) return compute_buy_amount_krw( cash, qty, price, 1.0, 1.0, asset_pct_scale=scale, fee_rate=TRADING_FEE_RATE, ) def _execute_order(self, hit: dict[str, Any]) -> dict[str, Any]: """ 매수·매도 주문 실행 또는 드라이런. Args: hit: evaluate_live_rules 항목. Returns: 로그용 결과 dict. """ side = hit["side"] price = float(hit["close"]) if side == "buy": amount_krw = self._resolve_buy_amount_krw(hit) if amount_krw <= 0: return { "ts": datetime.now().isoformat(timespec="seconds"), "rule_id": hit["rule_id"], "side": side, "signal_dt": hit["dt"], "price": price, "amount_krw": 0, "live_enabled": LIVE_TRADING_ENABLED, "ok": False, "message": "매수 스킵(EV/WF·leg·현금)", } else: amount_krw = float(LIVE_ORDER_KRW) record: dict[str, Any] = { "ts": datetime.now().isoformat(timespec="seconds"), "rule_id": hit["rule_id"], "side": side, "signal_dt": hit["dt"], "price": price, "amount_krw": amount_krw, "live_enabled": LIVE_TRADING_ENABLED, "ok": False, "message": "", } if not LIVE_TRADING_ENABLED: record["message"] = "dry_run (LIVE_TRADING_ENABLED=0)" record["ok"] = True return record try: if side == "buy": ok = self.buyCoinMarket(SYMBOL, int(amount_krw), count=None) record["ok"] = bool(ok) record["message"] = "buyCoinMarket" if ok else "buy failed" elif side == "sell": bal = self.load_balances_dict().get(SYMBOL, {}) qty = float(bal.get("balance") or 0) if qty <= 0: record["message"] = "보유 없음" else: gross = qty * price record["amount_krw"] = round(gross, 0) ok = self.sellCoinMarket(SYMBOL, int(price), qty) record["ok"] = bool(ok) record["message"] = f"sell qty={qty}" if ok else "sell failed" if record["ok"] and live_sizing_enabled(): fee = gross * TRADING_FEE_RATE self._position_state.record_sell( gross, fee, full_close=True ) self._position_state.save() else: record["message"] = f"unknown side {side}" except Exception as exc: record["message"] = str(exc) if record["ok"]: spent = float(record.get("amount_krw") or amount_krw) self._day_spent_krw += spent self._day_trades += 1 self._rule_last_unix[hit["rule_id"]] = time.time() if live_sizing_enabled() and side == "buy": fee = spent * TRADING_FEE_RATE self._position_state.record_buy(hit["dt"], price, spent, fee) self._position_state.save() return record def run_once(self) -> None: """1회: 규칙 평가 → (허용 시) 주문 → 텔레그램.""" rules = load_monitor_rules() print( f"[06] {datetime.now():%Y-%m-%d %H:%M:%S} " f"{COIN_NAME} live={'ON' if LIVE_TRADING_ENABLED else 'OFF'} " f"rules={len(rules)}" ) if not rules: print(" monitor_rules 없음") return fired = evaluate_live_rules(rules) balances = None try: balances = self.load_balances_dict() except Exception: pass if not fired: print(" 발화 없음") return for hit in fired: rid = hit["rule_id"] if hit["side"] == "buy" and hit["rule_id"] not in self._approved_rules: print(f" [{hit['side']}] {rid} @ {hit['dt']}") print(" skip: EV/WF 미통과 규칙") self._append_paper_fire( hit, 0.0, False, "EV/WF 미통과 규칙" ) continue planned = ( self._resolve_buy_amount_krw(hit) if hit["side"] == "buy" else float(LIVE_ORDER_KRW) ) ok, reason = self._can_trade(rid, planned) print(f" [{hit['side']}] {rid} @ {hit['dt']}") if not ok: print(f" skip: {reason}") self._append_paper_fire(hit, planned, False, reason) continue if hit["side"] == "buy" and planned <= 0: print(" skip: 매수금액 0") self._append_paper_fire(hit, 0.0, False, "매수금액 0") continue log = self._execute_order(hit) self._append_paper_fire(hit, planned, True, "", log) self._append_log(log) print(f" order: {log['message']} ok={log['ok']}") msg = build_rule_alert_message(hit, balances) if log["ok"]: msg += f"\n[체결] {log['message']}" else: msg += f"\n[실패] {log['message']}" self._send_coin_msg(msg) def run_loop(self, sleep_sec: int) -> None: """ 상시 루프. Args: sleep_sec: 대기 초. """ print(f"[06] 실거래 루프 시작 · sleep={sleep_sec}s") while True: self.run_once() time.sleep(sleep_sec)