Files
Bithumb/deepcoin/ops/live_trader.py
xavis d7848df6f7 refactor: GT·시뮬·운영 3축 정리 및 hybrid 실거래 정합
Phase C/dry-run·미사용 모듈·재생성 HTML을 제거하고, 운영 체결을
sim_causal_hybrid와 동일한 hybrid 로직으로 통합한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 23:50:28 +09:00

370 lines
13 KiB
Python

"""
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.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 실행")
return
fired = evaluate_live_rules(rules, force_refresh=True)
if not fired:
print(" 발화 없음")
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)
def run_loop(self, sleep_sec: int) -> None:
"""상시 루프."""
print(f"[06] 실거래 루프 시작 · sleep={sleep_sec}s")
while True:
self.run_once()
time.sleep(sleep_sec)