""" Phase C dry-run 모의 포트폴리오 — 시뮬 allocate_order_amounts_chronological 와 동일 현금·보유 규칙. """ from __future__ import annotations import json from pathlib import Path from typing import Any from config import ( GT_INITIAL_CASH_KRW, GT_MAX_SELLS_PER_LEG, GT_MIN_ORDER_KRW, SYMBOL, TRADING_FEE_RATE, ) from deepcoin.ground_truth.gt_allocation import resolve_sell_qty from deepcoin.ground_truth.gt_model import leg_exit_weights from deepcoin.paths import PAPER_FIRES_LOG, PAPER_PORTFOLIO_JSON class PaperPortfolio: """ dry-run 전용 현금·코인 보유 (초기 GT_INITIAL_CASH_KRW, 실거래 API 미사용). """ def __init__(self) -> None: """빈 모의 계좌.""" self.cash_krw: float = float(GT_INITIAL_CASH_KRW) self.qty: float = 0.0 self.qty_by_leg: dict[int, float] = {} self.current_leg_id: int = 0 self.sell_leg: int | None = None self.sell_base_qty: float = 0.0 self.sells_done_by_leg: dict[int, int] = {} self.processed_signals: list[str] = [] self.signal_history: list[dict[str, Any]] = [] @classmethod def load(cls, path: Path | None = None) -> PaperPortfolio: """ 디스크에서 복원. 없으면 GT_INITIAL_CASH_KRW(기본 40만 원). Args: path: JSON 경로. Returns: PaperPortfolio. """ p = path or PAPER_PORTFOLIO_JSON st = cls() if not p.is_file(): return st try: data = json.loads(p.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return st st.cash_krw = float(data.get("cash_krw", GT_INITIAL_CASH_KRW)) st.qty = float(data.get("qty") or 0.0) st.qty_by_leg = {int(k): float(v) for k, v in (data.get("qty_by_leg") or {}).items()} st.current_leg_id = int(data.get("current_leg_id") or 0) st.sell_leg = data.get("sell_leg") if st.sell_leg is not None: st.sell_leg = int(st.sell_leg) st.sell_base_qty = float(data.get("sell_base_qty") or 0.0) st.sells_done_by_leg = { int(k): int(v) for k, v in (data.get("sells_done_by_leg") or {}).items() } st.processed_signals = list(data.get("processed_signals") or [])[-500:] st.signal_history = list(data.get("signal_history") or [])[-2000:] if not st.signal_history: st._rebuild_signal_history_from_fires() return st def _rebuild_signal_history_from_fires(self) -> None: """ 구버전 paper(이력 없음) → paper_fires.jsonl 에서 would_trade 복원. """ if not PAPER_FIRES_LOG.is_file(): return seen: set[tuple[str, str, str]] = set() rows: list[dict[str, Any]] = [] try: for line in PAPER_FIRES_LOG.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue row = json.loads(line) if not row.get("would_trade"): continue dt = str(row.get("signal_dt") or "") rid = str(row.get("rule_id") or "") side = str(row.get("side") or "") if not dt or not rid or not side: continue key = (dt, rid, side) if key in seen: continue seen.add(key) rows.append( { "dt": dt, "rule_id": rid, "side": side, "close": float(row.get("close") or 0), } ) except (json.JSONDecodeError, OSError, TypeError, ValueError): return self.signal_history = rows[-2000:] def append_signal(self, hit: dict[str, Any]) -> None: """ 시뮬 재생용 발화 이력 추가 (dt·rule_id·side·close). Args: hit: evaluate_live_rules 항목. """ row = { "dt": str(hit["dt"]), "rule_id": str(hit["rule_id"]), "side": str(hit["side"]), "close": float(hit["close"]), } key = self.signal_key(row["rule_id"], row["dt"]) if key in self.processed_signals: return if any( s["dt"] == row["dt"] and s["rule_id"] == row["rule_id"] and s["side"] == row["side"] for s in self.signal_history ): return self.signal_history.append(row) def save(self, path: Path | None = None) -> None: """상태 저장.""" p = path or PAPER_PORTFOLIO_JSON p.parent.mkdir(parents=True, exist_ok=True) payload = { "cash_krw": round(self.cash_krw, 0), "qty": self.qty, "qty_by_leg": {str(k): round(v, 8) for k, v in self.qty_by_leg.items()}, "current_leg_id": self.current_leg_id, "sell_leg": self.sell_leg, "sell_base_qty": round(self.sell_base_qty, 8), "sells_done_by_leg": self.sells_done_by_leg, "processed_signals": self.processed_signals[-500:], "signal_history": self.signal_history[-2000:], "initial_cash_krw": GT_INITIAL_CASH_KRW, "sizing_engine": "sim_causal_hybrid", } p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") def balances_dict(self) -> dict[str, dict[str, float]]: """live_trader·alert용 잔고 dict.""" return { SYMBOL: { "balance": self.qty, "available_krw": self.cash_krw, "krw": self.cash_krw, } } def signal_key(self, rule_id: str, signal_dt: str) -> str: """동일 봉 중복 발화 방지 키.""" return f"{rule_id}|{signal_dt}" def already_processed(self, rule_id: str, signal_dt: str) -> bool: """이미 체결·스킵 처리한 신호인지.""" return self.signal_key(rule_id, signal_dt) in self.processed_signals def mark_processed(self, rule_id: str, signal_dt: str) -> None: """신호 처리 완료 표시.""" key = self.signal_key(rule_id, signal_dt) if key not in self.processed_signals: self.processed_signals.append(key) def active_leg_id(self) -> int | None: """보유 수량이 있는 leg_id (없으면 None).""" for lid, q in sorted(self.qty_by_leg.items()): if q > 1e-12: return lid return None def apply_buy(self, amount_krw: float, price: float, leg_id: int) -> bool: """ 모의 매수 체결. Args: amount_krw: 매수 원화. price: 체결가. leg_id: leg ID. Returns: 체결 성공 여부. """ if amount_krw <= 0 or price <= 0: return False fee = amount_krw * TRADING_FEE_RATE if self.cash_krw < amount_krw + fee: return False self.cash_krw -= amount_krw + fee bought = amount_krw / price self.qty += bought self.qty_by_leg[leg_id] = self.qty_by_leg.get(leg_id, 0.0) + bought self.current_leg_id = leg_id self.sell_leg = None self.sell_base_qty = 0.0 return True def plan_sell( self, price: float, leg_id: int | None = None, ) -> tuple[float, float, str]: """ 분할 매도 규모 (시뮬 leg_exit_weights·GT_MAX_SELLS_PER_LEG). Args: price: 체결가. leg_id: 대상 leg. None이면 active_leg_id. Returns: (amount_krw, sell_qty, skip_reason). skip_reason 비어 있으면 체결 가능. """ lid = leg_id if leg_id is not None else self.active_leg_id() if lid is None: return 0.0, 0.0, "모의 보유 없음" leg_qty = self.qty_by_leg.get(lid, 0.0) if leg_qty <= 1e-12: return 0.0, 0.0, "모의 보유 없음" if self.sell_leg != lid: self.sell_leg = lid self.sell_base_qty = leg_qty n_sells = max(1, int(GT_MAX_SELLS_PER_LEG)) weights = leg_exit_weights(n_sells) idx = self.sells_done_by_leg.get(lid, 0) is_last = idx >= len(weights) - 1 if is_last: sell_qty = leg_qty gross = sell_qty * price else: weight = float(weights[idx]) trade = {"amount_krw": None, "weight": weight} sell_qty = resolve_sell_qty( trade, leg_qty, price, self.sell_base_qty, weight ) gross = sell_qty * price if gross < GT_MIN_ORDER_KRW and leg_qty * price >= GT_MIN_ORDER_KRW: gross = GT_MIN_ORDER_KRW sell_qty = min(leg_qty, gross / price) if gross <= 0 or sell_qty <= 0: return 0.0, 0.0, "모의 매도 규모 0" return round(gross, 0), sell_qty, "" def apply_sell( self, amount_krw: float, sell_qty: float, price: float, leg_id: int, ) -> bool: """ 모의 매도 체결. Args: amount_krw: 매도 원화(총액). sell_qty: 매도 수량. price: 체결가. leg_id: leg ID. Returns: 체결 성공 여부. """ if sell_qty <= 0 or amount_krw <= 0: return False fee = amount_krw * TRADING_FEE_RATE self.cash_krw += amount_krw - fee leg_qty = self.qty_by_leg.get(leg_id, 0.0) - sell_qty self.qty_by_leg[leg_id] = max(leg_qty, 0.0) self.qty = max(self.qty - sell_qty, 0.0) if self.qty < 1e-12: self.qty = 0.0 self.sells_done_by_leg[leg_id] = self.sells_done_by_leg.get(leg_id, 0) + 1 if self.qty_by_leg.get(leg_id, 0.0) <= 1e-12: self.qty_by_leg.pop(leg_id, None) self.sell_leg = None self.sell_base_qty = 0.0 self.sells_done_by_leg.pop(leg_id, None) return True def equity_krw(self, mark_price: float) -> float: """ 총보유금액 = 현금 + 코인 평가(시세). Args: mark_price: 평가 단가. Returns: 원화 합계. """ return float(self.cash_krw) + float(self.qty) * float(mark_price) def summary(self, mark_price: float) -> dict[str, Any]: """ dry-run 모의 계좌 스냅샷 (빗썸 잔고와 무관). Args: mark_price: 최신 종가 등 평가 단가. Returns: initial·cash·qty·equity·pnl dict. """ initial = float(GT_INITIAL_CASH_KRW) equity = self.equity_krw(mark_price) pnl = equity - initial pnl_pct = (pnl / initial * 100.0) if initial > 0 else 0.0 coin_value = float(self.qty) * float(mark_price) return { "initial_cash_krw": round(initial, 0), "cash_krw": round(self.cash_krw, 0), "qty": round(self.qty, 8), "mark_price": round(mark_price, 4), "coin_value_krw": round(coin_value, 0), "equity_krw": round(equity, 0), "pnl_krw": round(pnl, 0), "pnl_pct": round(pnl_pct, 4), "source": "paper_portfolio.json (dry-run only, not Bithumb)", }