""" GT 공통 자본 배분·포트폴리오 시뮬 엔진. ground_truth.allocate_gt_order_amounts · simulate_truth_portfolio · matching/portfolio_sim 이 동일 규칙을 공유합니다. """ from __future__ import annotations from typing import Any, Callable from config import ( GT_INITIAL_CASH_KRW, GT_MIN_ORDER_KRW, TRADING_FEE_RATE, ) from deepcoin.ground_truth.gt_model import remaining_weight_sum def resolve_sell_qty( t: dict[str, Any], qty: float, price: float, sell_base_qty: float, weight: float, ) -> float: """ 매도 수량: amount_krw 우선, 없으면 sell_base_qty × weight. Args: t: trade dict. qty: 현재 보유 수량. price: 체결가. sell_base_qty: leg 첫 매도 시점 보유량. weight: 매도 비중. Returns: 매도 수량. """ if qty <= 0 or price <= 0: return 0.0 ak = t.get("amount_krw") if ak is not None and float(ak) > 0: gross_cap = float(ak) if gross_cap >= qty * price * 0.999: return qty return min(qty, gross_cap / price) return min(sell_base_qty * weight, qty) def allocate_order_amounts_chronological( trades: list[dict[str, Any]], *, initial_cash: float = GT_INITIAL_CASH_KRW, min_order_krw: float = GT_MIN_ORDER_KRW, fee_rate: float = TRADING_FEE_RATE, large_legs: set[int] | None = None, asset_pct_scale_fn: Callable[[dict[str, Any]], float] | None = None, causal_tier: bool = False, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: """ 시각순·leg 비중·티어 스케일로 amount_krw를 배분합니다. causal_tier=True: 청산 완료 leg의 realized return 만으로 tier 산정 (인과적). Args: trades: trade dict (weight·leg_id·action·price). initial_cash: 초기 현금. min_order_krw: 최소 체결 원화. fee_rate: 수수료율. large_legs: 대형 leg. None이면 GT trades에서 산출(비인과). asset_pct_scale_fn: 매수 trade별 tier scale. causal_tier: 과거 청산 leg 수익률만으로 tier. Returns: (amount_krw 채워진 trades, alloc_stats). """ from config import GT_LARGE_LEG_TOP_PCT from deepcoin.matching.position_sizing import ( compute_buy_amount_krw, large_leg_ids_from_past_returns, leg_asset_pct_scale, top_leg_ids_by_forward_return, ) chron = sorted(trades, key=lambda x: x["dt"]) if large_legs is None and not causal_tier: large_legs = top_leg_ids_by_forward_return(chron) elif large_legs is None: large_legs = set() leg_buy_idxs: dict[int, list[int]] = {} leg_sell_idxs: dict[int, list[int]] = {} for i, t in enumerate(chron): lid = int(t.get("leg_id", 0)) if t["action"] == "buy": leg_buy_idxs.setdefault(lid, []).append(i) elif t["action"] == "sell": leg_sell_idxs.setdefault(lid, []).append(i) cash = float(initial_cash) qty = 0.0 qty_by_leg: dict[int, float] = {} sell_leg: int | None = None sell_base_qty = 0.0 buy_executed = 0 buy_skipped = 0 sell_executed = 0 sell_skipped = 0 buy_amounts: list[float] = [] completed_leg_ret: dict[int, float] = {} leg_cost_krw: dict[int, float] = {} leg_proceeds_krw: dict[int, float] = {} for i, t in enumerate(chron): price = float(t["price"]) if price <= 0: continue leg_id = int(t.get("leg_id", 0)) weight = float(t.get("weight", 1.0)) if t["action"] == "buy": w_sum = remaining_weight_sum(chron, leg_id, i) if causal_tier: large_now = large_leg_ids_from_past_returns( completed_leg_ret, GT_LARGE_LEG_TOP_PCT ) scale = leg_asset_pct_scale(leg_id, large_now) elif asset_pct_scale_fn is not None: scale = asset_pct_scale_fn(t) else: scale = leg_asset_pct_scale(leg_id, large_legs) amount = compute_buy_amount_krw( cash, qty, price, weight, w_sum, asset_pct_scale=scale, min_order_krw=min_order_krw, fee_rate=fee_rate, ) if amount <= 0: t["amount_krw"] = 0 buy_skipped += 1 continue t["amount_krw"] = amount fee = amount * fee_rate cash -= amount + fee bought_qty = amount / price qty += bought_qty qty_by_leg[leg_id] = qty_by_leg.get(leg_id, 0.0) + bought_qty leg_cost_krw[leg_id] = leg_cost_krw.get(leg_id, 0.0) + amount + fee buy_executed += 1 buy_amounts.append(amount) sell_leg = None elif t["action"] == "sell": leg_qty = qty_by_leg.get(leg_id, 0.0) if leg_qty <= 1e-12: sell_skipped += 1 continue if sell_leg != leg_id: sell_leg = leg_id sell_base_qty = leg_qty rem_sells = [j for j in leg_sell_idxs.get(leg_id, []) if j >= i] is_last_leg_sell = bool(rem_sells) and i == rem_sells[-1] if is_last_leg_sell: sell_qty = leg_qty gross = sell_qty * price else: gross = sell_base_qty * weight * price if gross >= min_order_krw: gross = max(min_order_krw, gross) gross = min(gross, leg_qty * price) if gross <= 0: sell_skipped += 1 continue sell_qty = leg_qty if is_last_leg_sell else gross / price t["amount_krw"] = round(gross, 0) fee = gross * fee_rate cash += gross - fee leg_proceeds_krw[leg_id] = leg_proceeds_krw.get(leg_id, 0.0) + (gross - fee) leg_qty -= sell_qty qty_by_leg[leg_id] = max(leg_qty, 0.0) qty = max(qty - sell_qty, 0.0) if qty < 1e-12: qty = 0.0 sell_executed += 1 if causal_tier and leg_qty <= 1e-12: cost = leg_cost_krw.pop(leg_id, 0.0) proceeds = leg_proceeds_krw.pop(leg_id, 0.0) if cost > 0: completed_leg_ret[leg_id] = (proceeds - cost) / cost * 100.0 stats: dict[str, Any] = { "buy_executed": buy_executed, "buy_skipped": buy_skipped, "sell_executed": sell_executed, "sell_skipped": sell_skipped, "buy_total_krw": round(sum(buy_amounts), 0), "large_leg_count": len(large_legs), } if buy_amounts: stats["buy_amount_avg_krw"] = round(sum(buy_amounts) / len(buy_amounts), 0) stats["buy_amount_min_krw"] = round(min(buy_amounts), 0) stats["buy_amount_max_krw"] = round(max(buy_amounts), 0) return trades, stats def simulate_portfolio_steps( trades: list[dict[str, Any]], *, initial_cash: float = GT_INITIAL_CASH_KRW, fee_rate: float = TRADING_FEE_RATE, use_amount_krw: bool = True, ) -> list[dict[str, Any]]: """ 체결마다 현금·보유·총평가 스냅샷. Args: trades: 시각순 trade dict (amount_krw·weight·leg_id). initial_cash: 시작 현금. fee_rate: 수수료율. use_amount_krw: True면 amount_krw 기준 체결. Returns: step dict 리스트. """ rows = sorted(trades, key=lambda x: x["dt"]) cash = float(initial_cash) qty = 0.0 qty_by_leg: dict[int, float] = {} sell_leg: int | None = None sell_base_qty = 0.0 leg_budget = 0.0 current_leg: int | None = None steps: list[dict[str, Any]] = [] for t in rows: action = t.get("action", t.get("side", "")) price = float(t["price"]) if price <= 0: continue weight = float(t.get("weight", 1.0)) leg_id = int(t.get("leg_id", 0)) if action == "buy": if use_amount_krw and t.get("amount_krw") is not None and float(t["amount_krw"]) > 0: amount = min(float(t["amount_krw"]), max(cash / (1.0 + fee_rate), 0.0)) else: if leg_id != current_leg: current_leg = leg_id leg_budget = cash amount = min(leg_budget * weight, max(cash / (1.0 + fee_rate), 0.0)) if amount <= 0: continue fee = amount * fee_rate cash -= amount + fee bought = amount / price qty += bought qty_by_leg[leg_id] = qty_by_leg.get(leg_id, 0.0) + bought sell_leg = None elif action == "sell" and qty > 0: leg_qty = qty_by_leg.get(leg_id, qty) if sell_leg != leg_id: sell_leg = leg_id sell_base_qty = leg_qty sell_qty = resolve_sell_qty(t, leg_qty, price, sell_base_qty, weight) if sell_qty <= 0: continue gross = sell_qty * price fee = gross * fee_rate cash += gross - fee leg_qty -= sell_qty qty_by_leg[leg_id] = max(leg_qty, 0.0) qty -= sell_qty if qty < 1e-12: qty = 0.0 steps.append( { "dt": t["dt"], "action": action, "price": price, "weight": weight, "leg_id": leg_id, "amount_krw": t.get("amount_krw"), "cash_krw": round(cash, 0), "holding_qty": round(qty, 6), "total_asset_krw": round(cash + qty * price, 0), } ) return steps def compute_drawdown_metrics(steps: list[dict[str, Any]]) -> dict[str, Any]: """ equity curve 기준 최대 낙폭·고점 대비 하락. Args: steps: simulate_portfolio_steps 결과. Returns: max_drawdown_pct, peak_asset_krw, trough_after_peak_krw. """ if not steps: return { "max_drawdown_pct": 0.0, "peak_asset_krw": 0.0, "trough_asset_krw": 0.0, } assets = [float(s["total_asset_krw"]) for s in steps] peak = assets[0] max_dd = 0.0 peak_at = assets[0] trough_at = assets[0] for a in assets: if a > peak: peak = a dd = (peak - a) / peak * 100.0 if peak > 0 else 0.0 if dd > max_dd: max_dd = dd peak_at = peak trough_at = a return { "max_drawdown_pct": round(max_dd, 2), "peak_asset_krw": round(peak_at, 0), "trough_asset_krw": round(trough_at, 0), } def simulate_portfolio_summary( trades: list[dict[str, Any]], *, initial_cash: float = GT_INITIAL_CASH_KRW, fee_rate: float = TRADING_FEE_RATE, last_price: float | None = None, use_amount_krw: bool = True, ) -> dict[str, Any]: """ 포트폴리오 시뮬 요약 + MDD. Args: trades: trade dict 리스트. initial_cash: 시작 현금. fee_rate: 수수료율. last_price: 미청산 평가가. use_amount_krw: amount_krw 체결 사용. Returns: pnl·fee·MDD 포함 dict. """ steps = simulate_portfolio_steps( trades, initial_cash=initial_cash, fee_rate=fee_rate, use_amount_krw=use_amount_krw, ) if not steps: return { "initial_cash_krw": round(initial_cash, 0), "final_asset_krw": round(initial_cash, 0), "pnl_krw": 0.0, "pnl_pct": 0.0, "trade_count": len(trades), "max_drawdown_pct": 0.0, } last_step = steps[-1] cash = float(last_step["cash_krw"]) qty = float(last_step["holding_qty"]) mark = float(last_price if last_price is not None else last_step["price"]) holding_value = qty * mark final_asset = cash + holding_value pnl = final_asset - initial_cash pnl_pct = pnl / initial_cash * 100.0 if initial_cash else 0.0 fees = 0.0 for t in sorted(trades, key=lambda x: x["dt"]): ak = float(t.get("amount_krw") or 0) if ak <= 0: continue fees += ak * fee_rate dd = compute_drawdown_metrics(steps) return { "initial_cash_krw": round(initial_cash, 0), "final_asset_krw": round(final_asset, 0), "pnl_krw": round(pnl, 0), "pnl_pct": round(pnl_pct, 2), "total_fees_krw": round(fees, 0), "cash_krw": round(cash, 0), "holding_qty": round(qty, 6), "holding_value_krw": round(holding_value, 0), "mark_price": round(mark, 2), "fee_rate": fee_rate, "trade_count": len(trades), **dd, }