""" 3단계: monitor_rules 발화 시 빗썸 실주문 (가드·로그). 체결 배분: 시뮬 sim_causal_hybrid 와 동일 - fire_outcomes 부트스트랩 + hybrid_sim_execution.plan_live_hit - enhanced=False, hybrid DD tier, EV/WF 매수 필터 LIVE_TRADING_ENABLED=1 필수. """ from __future__ import annotations import json import time from datetime import date, datetime 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_HYBRID_BOOTSTRAP_FIRES, 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.matching.live_eval import evaluate_live_rules from deepcoin.matching.load_rules import load_monitor_rules from deepcoin.ops.alert_message import build_rule_alert_message from deepcoin.ops.hybrid_sim_execution import ( SimTradeResult, build_live_signal_history, hit_key, plan_live_hit, sort_hits_sim_order, ) from deepcoin.ops.monitor import Monitor from deepcoin.ops.portfolio_report import maybe_record_portfolio_snapshot from deepcoin.paths import ( LIVE_SIGNAL_HISTORY_JSON, LIVE_TRADES_LOG, ) class LiveTrader(Monitor): """ 규칙 발화 시 빗썸 실주문. 배분은 시뮬 sim_causal_hybrid 와 동일 엔진. """ def __init__(self) -> None: """Monitor 초기화, hybrid 이력·일별 카운터.""" if not LIVE_TRADING_ENABLED: raise RuntimeError( "LIVE_TRADING_ENABLED=0 — 실거래만 지원합니다. " ".env 에 LIVE_TRADING_ENABLED=1 설정 후 재기동하세요." ) 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._ohlc_df = None self._persisted_ops_signals: list[dict[str, Any]] = self._load_persisted_signals() self._live_signal_history = self._init_signal_history() self._load_ohlc_df() def _load_persisted_signals(self) -> list[dict[str, Any]]: """live_signal_history.json.""" if not LIVE_SIGNAL_HISTORY_JSON.is_file(): return [] try: data = json.loads(LIVE_SIGNAL_HISTORY_JSON.read_text(encoding="utf-8")) return list(data.get("signals") or []) except (json.JSONDecodeError, OSError): return [] def _init_signal_history(self) -> list[dict[str, Any]]: """ 시뮬과 동일 hybrid 입력: fire_outcomes 부트스트랩 + 운영 저장분. Returns: 병합된 발화 이력. """ merged = build_live_signal_history(self._persisted_ops_signals) n_boot = max(len(merged) - len(self._persisted_ops_signals), 0) print( f"[06] hybrid 이력: total={len(merged)} " f"(bootstrap={'on' if LIVE_HYBRID_BOOTSTRAP_FIRES else 'off'}, " f"from_fires~{n_boot}, ops_persisted={len(self._persisted_ops_signals)})" ) return merged def _save_persisted_ops_signals(self) -> None: """운영 체결분만 저장 (fire_outcomes 부트스트랩은 재로드).""" LIVE_SIGNAL_HISTORY_JSON.parent.mkdir(parents=True, exist_ok=True) LIVE_SIGNAL_HISTORY_JSON.write_text( json.dumps( {"signals": self._persisted_ops_signals[-2000:]}, ensure_ascii=False, indent=2, ), encoding="utf-8", ) def _live_signal_seen(self, hit: dict[str, Any]) -> bool: """이력에 동일 봉 발화가 있는지.""" dt, rid, side = hit_key(hit) return any( str(s["dt"]) == dt and str(s["rule_id"]) == rid and str(s["side"]) == side for s in self._live_signal_history ) def _append_live_signal(self, hit: dict[str, Any]) -> None: """체결 성공 발화를 전체 이력·운영 저장분에 추가.""" if self._live_signal_seen(hit): return row = { "dt": str(hit["dt"]), "rule_id": str(hit["rule_id"]), "side": str(hit["side"]), "close": float(hit["close"]), } self._live_signal_history.append(row) if not any(hit_key(s) == hit_key(row) for s in self._persisted_ops_signals): self._persisted_ops_signals.append(row) 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.""" 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 _can_trade(self, rule_id: str, planned_krw: float | None = None) -> tuple[bool, str]: """ 쿨다운(1봉=3분) + 일한도·손실한도·거래횟수. Args: rule_id: 규칙 ID. planned_krw: 예정 매수 원화. Returns: (허용 여부, 거절 사유). """ self._reset_day_if_needed() last = self._rule_last_unix.get(rule_id, 0.0) if time.time() - last < LIVE_COOLDOWN_MIN * 60: return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)" 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, "일 손실 한도 초과" 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 _sim_plan(self, hit: dict[str, Any]) -> SimTradeResult: """시뮬 hybrid 배분 1건 (누적 이력·인과, 현금·보유 제약).""" if self._ohlc_df is None: self._load_ohlc_df() return plan_live_hit( list(self._live_signal_history), hit, self._ohlc_df, approved_buy_rules=None, ) @staticmethod def _cap_plan_to_exchange(plan: SimTradeResult, hit: dict[str, Any], balances: dict) -> SimTradeResult: """ 시뮬 planned 금액을 거래소 가용 잔고 이내로 제한. Args: plan: hybrid 시뮬 배분 결과. hit: 발화. balances: load_balances_dict() 결과. Returns: 조정된 SimTradeResult. """ sym = balances.get(SYMBOL, {}) price = float(hit["close"]) side = hit["side"] if not plan.ok or plan.amount_krw <= 0: return plan if side == "buy": krw = float(sym.get("krw") or 0) max_buy = max(krw / (1.0 + TRADING_FEE_RATE) - 1.0, 0.0) capped = min(float(plan.amount_krw), max_buy) if capped <= 0: return SimTradeResult( plan.hit, 0.0, 0.0, False, "거래소 현금 부족(시뮬 대비)" ) if capped < plan.amount_krw - 1.0: return SimTradeResult( plan.hit, round(capped, 0), 0.0, True, f"sim_buy capped ₩{capped:,.0f} (plan ₩{plan.amount_krw:,.0f})", leg_id=plan.leg_id, ) return plan held = float(sym.get("balance") or 0) if held <= 0: return SimTradeResult(plan.hit, 0.0, 0.0, False, "거래소 보유 없음") sell_qty = min(float(plan.sell_qty), held) if sell_qty <= 0: return SimTradeResult(plan.hit, 0.0, 0.0, False, "매도 수량 0") gross = round(sell_qty * price, 0) if sell_qty < plan.sell_qty - 1e-8: return SimTradeResult( plan.hit, gross, sell_qty, True, f"sim_sell capped qty={sell_qty:.4f}", leg_id=plan.leg_id, ) return plan def _execute_live_order( self, hit: dict[str, Any], plan: SimTradeResult, balances: dict ) -> dict[str, Any]: """실거래: 시뮬 plan(잔고 cap)으로 API 주문.""" side = hit["side"] price = float(hit["close"]) plan = self._cap_plan_to_exchange(plan, hit, balances) 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": plan.amount_krw, "live_enabled": True, "ok": False, "message": plan.message, "sizing": "sim_causal_hybrid", } if not plan.ok: return record try: if side == "buy": ok = self.buyCoinMarket(SYMBOL, int(plan.amount_krw), count=None) record["ok"] = bool(ok) record["message"] = "buyCoinMarket" if ok else "buy failed" elif side == "sell": sell_qty = float(plan.sell_qty) gross = sell_qty * price record["amount_krw"] = round(gross, 0) ok = self.sellCoinMarket(SYMBOL, int(price), sell_qty) record["ok"] = bool(ok) record["sell_qty"] = sell_qty record["message"] = ( f"sell qty={sell_qty:.4f}" if ok else "sell failed" ) 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 plan.amount_krw) self._day_spent_krw += spent self._day_trades += 1 self._rule_last_unix[hit["rule_id"]] = time.time() self._append_live_signal(hit) self._save_persisted_ops_signals() return record def run_once(self) -> None: """1회: 규칙 평가 → hybrid 배분 → 빗썸 주문 → 텔레그램.""" from deepcoin.data.ops_sync import ensure_ops_candles ensure_ops_candles() rules = load_monitor_rules() print( f"[06] {datetime.now():%Y-%m-%d %H:%M:%S} " f"{COIN_NAME} LIVE rules={len(rules)} · sim=hybrid · bar={MATCH_PRIMARY_INTERVAL}m" ) if not rules: print(" monitor_rules 없음 — scripts/04_match_rules.py 실행") maybe_record_portfolio_snapshot(self) return fired = evaluate_live_rules(rules, force_refresh=True) if not fired: print(" 발화 없음") maybe_record_portfolio_snapshot(self) return try: balances = self.load_balances_dict() except Exception: balances = {} for hit in sort_hits_sim_order(fired): rid = hit["rule_id"] if self._live_signal_seen(hit): continue plan_preview = self._sim_plan(hit) ok, reason = self._can_trade(rid, plan_preview.amount_krw) if not ok: print(f" [{hit['side']}] {rid} @ {hit['dt']}") print(f" skip: {reason}") continue if not plan_preview.ok: print(f" [{hit['side']}] {rid} @ {hit['dt']}") print(f" skip: {plan_preview.message}") continue print(f" [{hit['side']}] {rid} @ {hit['dt']}") log = self._execute_live_order(hit, plan_preview, balances) self._append_log(log) print(f" order: {log['message']} ok={log['ok']}") if not log["ok"]: continue try: balances = self.load_balances_dict() except Exception: balances = None msg = build_rule_alert_message( hit, balances, trade_krw=float(log.get("amount_krw") or 0), trade_qty=float(log.get("sell_qty") or 0) or None, ) if balances: sym = balances.get(SYMBOL, {}) msg += ( f"\n[잔고] 현금 ₩{float(sym.get('krw', 0)):,.0f} · " f"보유 {float(sym.get('balance', 0)):.4f} {SYMBOL}" ) msg += f"\n[체결] {log['message']}" self._send_coin_msg(msg) maybe_record_portfolio_snapshot(self) def run_loop(self, sleep_sec: int) -> None: """상시 루프.""" print(f"[06] 실거래 루프 시작 · sleep={sleep_sec}s") while True: self.run_once() time.sleep(sleep_sec)