"""가상 포트폴리오 — 현금·포지션·체결·비용·손익·MDD. 상태: state/sim/portfolio.json 거래로그: state/sim/trades.jsonl (append) 실제 주문 X — 전부 장부상 가상 체결. """ from __future__ import annotations import json from datetime import datetime from . import config from .signals import target_value as _sizing_value # buy()의 signals 파라미터가 모듈명을 가려서 직접 import def _now_iso() -> str: return datetime.now(config.KST).isoformat() class Portfolio: def __init__(self, data: dict): self.cash: float = data.get('cash', config.INITIAL_CAPITAL) self.initial: float = data.get('initial', config.INITIAL_CAPITAL) self.positions: dict[str, dict] = data.get('positions', {}) self.realized_pnl: float = data.get('realized_pnl', 0.0) self.closed_trades: int = data.get('closed_trades', 0) self.wins: int = data.get('wins', 0) self.peak_equity: float = data.get('peak_equity', self.initial) self.max_drawdown: float = data.get('max_drawdown', 0.0) self.created_at: str = data.get('created_at', _now_iso()) self.updated_at: str = data.get('updated_at', self.created_at) self.last_exit: dict[str, str] = data.get('last_exit', {}) # code → 전량청산 날짜(YYYY-MM-DD), 당일 재진입 금지용 # 영속화 경로 (변이는 별도 경로, 기본은 메인 sim) self._pf_path = config.PORTFOLIO_PATH self._trades_path = config.TRADES_PATH # ---- 영속화 ---- @classmethod def load(cls, portfolio_path=None, trades_path=None) -> 'Portfolio': pp = portfolio_path or config.PORTFOLIO_PATH tp = trades_path or config.TRADES_PATH data = {} if pp.exists(): try: data = json.loads(pp.read_text()) except Exception: data = {} obj = cls(data) obj._pf_path = pp obj._trades_path = tp return obj def save(self): self._pf_path.parent.mkdir(parents=True, exist_ok=True) self.updated_at = _now_iso() self._pf_path.write_text(json.dumps({ 'cash': self.cash, 'initial': self.initial, 'positions': self.positions, 'realized_pnl': self.realized_pnl, 'closed_trades': self.closed_trades, 'wins': self.wins, 'peak_equity': self.peak_equity, 'max_drawdown': self.max_drawdown, 'last_exit': self.last_exit, 'created_at': self.created_at, 'updated_at': self.updated_at, }, ensure_ascii=False, indent=2)) def _log_trade(self, rec: dict): self._trades_path.parent.mkdir(parents=True, exist_ok=True) with self._trades_path.open('a') as f: f.write(json.dumps(rec, ensure_ascii=False) + '\n') # ---- 체결 ---- def can_open(self) -> bool: return len(self.positions) < config.MAX_POSITIONS def target_position_value(self) -> float: return self.equity_estimate() / config.MAX_POSITIONS def equity_estimate(self) -> float: """현재 포지션의 진입가 기준 추정 자산 (사이징용 — mark 전 호출 대비).""" held = sum(p['qty'] * p.get('cur_price', p['entry_price']) for p in self.positions.values()) return self.cash + held def _qty_for(self, fill_price: float, value: float) -> int: return int(value // (fill_price * (1 + config.COMMISSION_RATE))) def buy(self, code, name, fill_price, sources, stop, target, path, reason, signals=None) -> dict | None: """신규 진입(1차 트랜치). 목표비중을 ENTRY_TRANCHES 로 나눈 만큼만 매수. 체결 dict 또는 None.""" if code in self.positions or not self.can_open(): return None full_target = _sizing_value(self.equity_estimate(), fill_price, stop) tranche_val = full_target / max(1, config.ENTRY_TRANCHES) qty = self._qty_for(fill_price, tranche_val) if qty < 1: return None cost = qty * fill_price * (1 + config.COMMISSION_RATE) if cost > self.cash: qty = self._qty_for(fill_price, self.cash) if qty < 1: return None cost = qty * fill_price * (1 + config.COMMISSION_RATE) self.cash -= cost self.positions[code] = { 'code': code, 'name': name, 'sources': sources, 'qty': qty, 'entry_price': fill_price, 'entry_at': _now_iso(), 'stop': stop, 'target': target, 'peak': fill_price, 'trailing_on': False, 'scaled_out': False, 'entry_path': path, 'entry_reason': reason, 'cur_price': fill_price, 'tranches': 1, 'tranche_value': tranche_val, 'last_add_price': fill_price, } rec = {'ts': _now_iso(), 'side': 'BUY', 'code': code, 'name': name, 'price': fill_price, 'qty': qty, 'cost': round(cost), 'path': path, 'reason': reason, 'stop': stop, 'target': target, 'signals': signals or []} self._log_trade(rec) return rec def add_tranche(self, code, fill_price, reason, signals=None) -> dict | None: """추격매수 — 보유 종목에 다음 트랜치 추가. 평단·수량 갱신. 체결 dict 또는 None.""" pos = self.positions.get(code) if not pos or pos.get('tranches', 1) >= config.ENTRY_TRANCHES: return None tranche_val = pos.get('tranche_value') or (pos['entry_price'] * pos['qty']) qty = self._qty_for(fill_price, tranche_val) if qty < 1: return None cost = qty * fill_price * (1 + config.COMMISSION_RATE) if cost > self.cash: qty = self._qty_for(fill_price, self.cash) if qty < 1: return None cost = qty * fill_price * (1 + config.COMMISSION_RATE) new_qty = pos['qty'] + qty pos['entry_price'] = (pos['entry_price'] * pos['qty'] + fill_price * qty) / new_qty pos['qty'] = new_qty pos['tranches'] = pos.get('tranches', 1) + 1 pos['last_add_price'] = fill_price pos['cur_price'] = fill_price self.cash -= cost rec = {'ts': _now_iso(), 'side': 'BUY', 'code': code, 'name': pos['name'], 'price': fill_price, 'qty': qty, 'cost': round(cost), 'path': 'add', 'reason': reason, 'stop': pos['stop'], 'target': pos['target'], 'signals': signals or []} self._log_trade(rec) return rec def sell(self, code, fill_price, reason, signals=None, frac: float = 1.0) -> dict | None: """매도. frac<1 이면 부분(분할)매도 — 포지션 유지하고 수량만 차감.""" pos = self.positions.get(code) if not pos: return None total = pos['qty'] qty = total if frac >= 1.0 else max(1, int(total * frac)) if qty >= total: qty = total entry = pos['entry_price'] proceeds = qty * fill_price * (1 - config.COMMISSION_RATE - config.SELL_TAX_RATE) entry_cost = qty * entry * (1 + config.COMMISSION_RATE) pnl = proceeds - entry_cost self.cash += proceeds self.realized_pnl += pnl partial = qty < total rec = {'ts': _now_iso(), 'side': 'SELL', 'code': code, 'name': pos['name'], 'price': fill_price, 'qty': qty, 'proceeds': round(proceeds), 'entry_price': round(entry), 'pnl': round(pnl), 'pnl_pct': round((fill_price / entry - 1) * 100, 2), 'hold_from': pos['entry_at'], 'reason': reason, 'partial': partial, 'signals': signals or []} self._log_trade(rec) if partial: pos['qty'] = total - qty else: self.closed_trades += 1 if pnl > 0: self.wins += 1 del self.positions[code] self.last_exit[code] = _now_iso()[:10] # 당일 재진입 금지 기준 return rec def exited_today(self, code: str) -> bool: """오늘 전량청산한 종목인지 — 당일 재진입(휩쏘 churn) 방지.""" return self.last_exit.get(code) == _now_iso()[:10] def apply_position_update(self, code: str, upd: dict): if code in self.positions: self.positions[code].update(upd) def mark(self, code: str, cur_price: int): if code in self.positions: self.positions[code]['cur_price'] = cur_price # ---- 지표 ---- def update_equity_metrics(self): eq = self.equity_estimate() if eq > self.peak_equity: self.peak_equity = eq dd = (self.peak_equity - eq) / self.peak_equity if self.peak_equity else 0.0 if dd > self.max_drawdown: self.max_drawdown = dd return eq def summary(self) -> dict: eq = self.equity_estimate() held_val = eq - self.cash return { 'initial': self.initial, 'cash': round(self.cash), 'held_value': round(held_val), 'equity': round(eq), 'total_return_pct': round((eq / self.initial - 1) * 100, 2), 'realized_pnl': round(self.realized_pnl), 'closed_trades': self.closed_trades, 'wins': self.wins, 'win_rate_pct': round(self.wins / self.closed_trades * 100, 1) if self.closed_trades else None, 'open_positions': len(self.positions), 'max_drawdown_pct': round(self.max_drawdown * 100, 2), 'updated_at': self.updated_at, }