- 1분봉 다운로드 제외, MONITOR_PERSIST로 05/06 수집 시 coins.db INSERT - Phase C paper_fires 로그·07 모의 리포트, hybrid 시뮬 산출물·reference 문서 갱신 - .env Phase C(LIVE=0), bootstrap dotenv override=True Co-authored-by: Cursor <cursoragent@cursor.com>
356 lines
12 KiB
Python
356 lines
12 KiB
Python
"""
|
|
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)
|