40만 원 기준 시뮬·dry-run 정합 및 hybrid 체결 엔진 통합.
초기 자금 GT_INITIAL_CASH_KRW=400000과 원화 한도 비율(알림·LIVE_ORDER·일한도·손실한도)을 맞추고, dry-run/live 체결을 sim_causal_hybrid(replay)와 동일 경로로 통합한다. 시뮬 리포트 갱신, Phase C 슈퍼바이저·매수매도 리허설 스크립트를 추가한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
337
deepcoin/ops/paper_portfolio.py
Normal file
337
deepcoin/ops/paper_portfolio.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
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)",
|
||||
}
|
||||
Reference in New Issue
Block a user