""" 3단계: monitor_rules 발화 시 빗썸 실주문 (가드·로그). dry-run·live 체결 배분: 시뮬 sim_causal_hybrid 와 동일 (hybrid_sim_execution). """ 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, GT_INITIAL_CASH_KRW, 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 ( load_ev_wf_approved_rule_ids, top_leg_ids_by_forward_return, ) from deepcoin.ops.alert_message import build_rule_alert_message from deepcoin.ops.hybrid_sim_execution import ( hit_key, plan_live_hit, replay_paper_portfolio, sort_hits_sim_order, ) from deepcoin.ops.monitor import Monitor from deepcoin.ops.paper_portfolio import PaperPortfolio from deepcoin.paths import ( LIVE_SIGNAL_HISTORY_JSON, LIVE_TRADES_LOG, PAPER_FIRES_LOG, resolve_ground_truth_file, ) class LiveTrader(Monitor): """ 규칙 발화 시 실거래 실행. LIVE_TRADING_ENABLED=0 이면 모의(sim hybrid)만. """ 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._paper = PaperPortfolio.load() if not LIVE_TRADING_ENABLED else None self._live_signal_history: list[dict[str, Any]] = [] self._ohlc_df = None self._load_sizing_context() if self._paper_mode and self._paper.signal_history: self._resync_paper_from_sim() if LIVE_TRADING_ENABLED: self._live_signal_history = self._load_live_signal_history() @property def _paper_mode(self) -> bool: """dry-run: 모의 계좌·시뮬 hybrid 체결.""" return not LIVE_TRADING_ENABLED and self._paper is not None def _load_live_signal_history(self) -> list[dict[str, Any]]: """live 시뮬 정합용 발화 이력.""" 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 _save_live_signal_history(self) -> None: """live 발화 이력 저장.""" LIVE_SIGNAL_HISTORY_JSON.parent.mkdir(parents=True, exist_ok=True) LIVE_SIGNAL_HISTORY_JSON.write_text( json.dumps( {"signals": self._live_signal_history[-2000:]}, ensure_ascii=False, indent=2, ), encoding="utf-8", ) def _live_signal_seen(self, hit: dict[str, Any]) -> bool: """live 이력에 동일 봉 발화가 있는지.""" 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: """live 발화 이력 추가.""" if self._live_signal_seen(hit): return self._live_signal_history.append( { "dt": str(hit["dt"]), "rule_id": str(hit["rule_id"]), "side": str(hit["side"]), "close": float(hit["close"]), } ) def _balances_for_trading(self) -> dict[str, dict[str, float]] | None: """ dry-run: paper_portfolio만. live: 빗썸 API. """ if self._paper_mode: return self._paper.balances_dict() try: return self.load_balances_dict() except Exception: return None 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.""" 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 paper_fires.jsonl.""" 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", ""), "sizing": "sim_causal_hybrid", } 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]: """ 쿨다운(1봉=3분) + live 일한도. dry-run은 일한도만 생략. """ 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._paper_mode: return True, "" 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 _resync_paper_from_sim(self) -> None: """기존 paper 잔고를 sim_causal_hybrid replay 로 맞춤.""" if self._ohlc_df is None: self._load_ohlc_df() if self._ohlc_df is None or getattr(self._ohlc_df, "empty", True): return replayed, _ = replay_paper_portfolio( self._paper.signal_history, self._ohlc_df, approved_buy_rules=self._approved_rules, ) self._paper.cash_krw = replayed.cash_krw self._paper.qty = replayed.qty self._paper.qty_by_leg = dict(replayed.qty_by_leg) self._paper.current_leg_id = replayed.current_leg_id self._paper.save() def _sim_plan(self, hit: dict[str, Any]) -> Any: """시뮬 hybrid 배분 1건.""" if self._ohlc_df is None: self._load_ohlc_df() if self._paper_mode: hist = list(self._paper.signal_history) else: hist = list(self._live_signal_history) return plan_live_hit( hist, hit, self._ohlc_df, approved_buy_rules=self._approved_rules, ) def _execute_live_order(self, hit: dict[str, Any], plan: Any) -> dict[str, Any]: """실거래: 시뮬 planned 금액·수량으로 API 주문.""" side = hit["side"] price = float(hit["close"]) 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": bal = self.load_balances_dict().get(SYMBOL, {}) held = float(bal.get("balance") or 0) if held <= 0: record["message"] = "보유 없음" else: sell_qty = min(float(plan.sell_qty), held) if sell_qty <= 0: record["message"] = "매도 수량 0" else: 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" ) if record["ok"] and live_sizing_enabled(): fee = gross * TRADING_FEE_RATE self._position_state.record_sell( gross, fee, full_close=(sell_qty >= held * 0.999) ) 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 plan.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() self._append_live_signal(hit) self._save_live_signal_history() return record def _process_paper_batch(self, new_hits: list[dict[str, Any]]) -> None: """ dry-run: 신규 발화를 이력에 넣고 시뮬 전체 재생 후 알림. """ if not new_hits: return if self._ohlc_df is None: self._load_ohlc_df() for hit in new_hits: self._paper.append_signal(hit) replayed, results = replay_paper_portfolio( self._paper.signal_history, self._ohlc_df, approved_buy_rules=self._approved_rules, ) self._paper.cash_krw = replayed.cash_krw self._paper.qty = replayed.qty self._paper.qty_by_leg = dict(replayed.qty_by_leg) self._paper.current_leg_id = replayed.current_leg_id for hit in new_hits: key = hit_key(hit) res = results.get(key) if res is None: self._paper.mark_processed(hit["rule_id"], hit["dt"]) continue log = { "ok": res.ok, "message": res.message, "amount_krw": res.amount_krw, "sell_qty": res.sell_qty, } self._append_paper_fire( hit, res.amount_krw, res.ok, "" if res.ok else res.message, log ) self._paper.mark_processed(hit["rule_id"], hit["dt"]) print(f" [{hit['side']}] {hit['rule_id']} @ {hit['dt']}") print(f" order: {res.message} ok={res.ok}") if not res.ok: continue self._rule_last_unix[hit["rule_id"]] = time.time() post_balances = self._paper.balances_dict() msg = build_rule_alert_message( hit, post_balances, trade_krw=res.amount_krw, trade_qty=res.sell_qty if hit["side"] == "sell" else None, ) sym = post_balances.get(SYMBOL, {}) msg += ( f"\n[모의잔고·체결후] 현금 {_fmt_paper_krw(sym.get('krw', 0))} · " f"보유 {float(sym.get('balance', 0)):.4f} {SYMBOL}" ) msg += f"\n[체결] {res.message}" self._send_coin_msg(msg) self._paper.save() def run_once(self) -> None: """1회: 규칙 평가 → 시뮬 hybrid 체결 → 텔레그램.""" 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)} · sim=hybrid · bar={MATCH_PRIMARY_INTERVAL}m" ) if not rules: print(" monitor_rules 없음") return fired = evaluate_live_rules(rules, force_refresh=True) if not fired: print(" 발화 없음") return if self._paper_mode: print( f" [paper] 현금 ₩{self._paper.cash_krw:,.0f} · " f"보유 {self._paper.qty:.4f} {SYMBOL} " f"(초기 ₩{GT_INITIAL_CASH_KRW:,.0f})" ) new_paper_hits: list[dict[str, Any]] = [] for hit in sort_hits_sim_order(fired): rid = hit["rule_id"] if self._paper_mode and self._paper.already_processed(rid, hit["dt"]): print(f" [{hit['side']}] {rid} @ {hit['dt']} (이미 처리)") continue if LIVE_TRADING_ENABLED and self._live_signal_seen(hit): continue if hit["side"] == "buy" and rid not in self._approved_rules: print(f" [{hit['side']}] {rid} @ {hit['dt']}") print(" skip: EV/WF 미통과 규칙") if self._paper_mode: self._append_paper_fire(hit, 0.0, False, "EV/WF 미통과 규칙") self._paper.mark_processed(rid, hit["dt"]) 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}") if self._paper_mode: self._append_paper_fire( hit, plan_preview.amount_krw, False, reason ) self._paper.mark_processed(rid, hit["dt"]) continue if self._paper_mode: new_paper_hits.append(hit) continue print(f" [{hit['side']}] {rid} @ {hit['dt']}") log = self._execute_live_order(hit, plan_preview) self._append_log(log) print(f" order: {log['message']} ok={log['ok']}") if not log["ok"]: continue balances = self._balances_for_trading() 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[잔고] 현금 {_fmt_paper_krw(sym.get('krw', 0))} · " f"보유 {float(sym.get('balance', 0)):.4f} {SYMBOL}" ) msg += f"\n[체결] {log['message']}" self._send_coin_msg(msg) if self._paper_mode and new_paper_hits: self._process_paper_batch(new_paper_hits) def run_loop(self, sleep_sec: int) -> None: """상시 루프.""" print(f"[06] 실거래 루프 시작 · sleep={sleep_sec}s") while True: self.run_once() time.sleep(sleep_sec) def _fmt_paper_krw(value: float) -> str: """원화 표시.""" return f"₩{float(value):,.0f}"