""" 규칙 발화 기반 고정 금액 체결 포트폴리오 시뮬 (GT HTML 카드·테이블용). """ from __future__ import annotations from typing import Any import pandas as pd from config import ( GT_INITIAL_CASH_KRW, LIVE_DAILY_KRW_MAX, LIVE_MAX_TRADES_PER_DAY, LIVE_ORDER_KRW, TRADING_FEE_RATE, ) from deepcoin.matching.position_sizing import ( attach_dynamic_buy_amounts, load_sizing_context_from_gt, ) def _planned_order_krw( t: dict[str, Any], order_krw: float, sizing_mode: str, ) -> float: """ 체결 계획 원화: amount_krw 우선 또는 고정. Args: t: trade dict. order_krw: 고정 1회 금액. sizing_mode: fixed | amount_krw. Returns: 계획 원화. """ ak = t.get("amount_krw") if sizing_mode == "amount_krw" or (ak is not None and float(ak) > 0): return float(ak or 0) return float(order_krw) def select_capped_fires( fires: pd.DataFrame, *, use_dynamic_sizing: bool = True, ) -> pd.DataFrame: """ 일한도·회수 제한으로 체결 가능한 발화만 남깁니다. Args: fires: fire_outcomes (dt, side, close, rule_id …). Returns: 체결된 발화 DataFrame. """ if fires.empty: return fires gt_trades, large_legs, approved = load_sizing_context_from_gt() df = fires.sort_values("dt").copy() df["ts"] = pd.to_datetime(df["dt"]) df["day"] = df["ts"].dt.date.astype(str) cash = float(GT_INITIAL_CASH_KRW) qty = 0.0 taken: list[pd.DataFrame] = [] for _, day_grp in df.groupby("day", sort=True): spent = 0.0 n_trades = 0 idxs: list[Any] = [] for idx, row in day_grp.iterrows(): if n_trades >= LIVE_MAX_TRADES_PER_DAY: break side = row["side"] price = float(row["close"]) if side == "buy" and use_dynamic_sizing: from deepcoin.matching.position_sizing import ( compute_buy_amount_krw, live_buy_asset_pct_scale, ) scale = live_buy_asset_pct_scale( str(row["rule_id"]), str(row["dt"]), gt_trades, approved_rules=approved, large_legs=large_legs, ) planned = compute_buy_amount_krw( cash, qty, price, 1.0, 1.0, asset_pct_scale=scale, ) else: planned = float(LIVE_ORDER_KRW) if side == "buy": if spent + planned > LIVE_DAILY_KRW_MAX: break if planned <= 0: continue fee = planned * TRADING_FEE_RATE cash -= planned + fee qty += planned / price if price > 0 else 0.0 spent += planned elif side == "sell" and qty > 0: gross = qty * price cash += gross * (1.0 - TRADING_FEE_RATE) qty = 0.0 n_trades += 1 idxs.append(idx) if idxs: taken.append(day_grp.loc[idxs]) if not taken: return df.iloc[0:0] return pd.concat(taken, ignore_index=True) def fires_to_trade_list( fires: pd.DataFrame, *, apply_dynamic_sizing: bool = True, ) -> list[dict[str, Any]]: """ 발화 DataFrame을 포트폴리오 시뮬용 trade dict 리스트로 변환. Args: fires: 체결 대상 발화. Returns: dt, action, price 키를 가진 dict 리스트. """ rows: list[dict[str, Any]] = [] for _, r in fires.sort_values("dt").iterrows(): rows.append( { "dt": str(r["dt"]), "action": r["side"], "price": float(r["close"]), "rule_id": r.get("rule_id", ""), "forward_ret_pct": float(r.get("forward_ret_pct", 0)), } ) if apply_dynamic_sizing and rows: gt_trades, large_legs, approved = load_sizing_context_from_gt() attach_dynamic_buy_amounts( rows, gt_trades=gt_trades, approved_rules=approved, large_legs=large_legs, ) return rows def simulate_sized_portfolio( trades: list[dict[str, Any]], initial_cash: float = GT_INITIAL_CASH_KRW, fee_rate: float = TRADING_FEE_RATE, last_price: float | None = None, fallback_order_krw: float = LIVE_ORDER_KRW, ) -> dict[str, Any]: """ trade.amount_krw(총자산 비율 배분) 기준 포트폴리오 시뮬. Args: trades: 시간순 trade dict (amount_krw 권장). initial_cash: 시작 현금. fee_rate: 수수료율. last_price: 미청산 평가 종가. fallback_order_krw: amount_krw 없을 때 1회 금액. Returns: simulate_truth_portfolio와 동일 키 구조. """ return simulate_fixed_order_portfolio( trades, order_krw=fallback_order_krw, initial_cash=initial_cash, fee_rate=fee_rate, last_price=last_price, sizing_mode="amount_krw", ) def simulate_fixed_order_portfolio( trades: list[dict[str, Any]], order_krw: float = LIVE_ORDER_KRW, initial_cash: float = GT_INITIAL_CASH_KRW, fee_rate: float = TRADING_FEE_RATE, last_price: float | None = None, sizing_mode: str = "fixed", ) -> dict[str, Any]: """ 포트폴리오 시뮬 (고정 원화 또는 trade.amount_krw). Args: trades: 시간순 {dt, action, price, amount_krw?}. order_krw: sizing_mode=fixed 일 때 1회 금액(원). initial_cash: 시작 현금. fee_rate: 수수료율. last_price: 미청산 평가 종가. sizing_mode: 'fixed' | 'amount_krw' (없으면 order_krw). Returns: simulate_truth_portfolio와 동일 키 구조. """ cash = float(initial_cash) qty = 0.0 total_fees = 0.0 last_trade_price = last_price order = float(order_krw) for t in sorted(trades, key=lambda x: x["dt"]): action = t["action"] price = float(t["price"]) if price <= 0: continue last_trade_price = price if action == "buy": planned = _planned_order_krw(t, order, sizing_mode) amount = min(planned, max(cash / (1.0 + fee_rate), 0.0)) if amount <= 0: continue fee = amount * fee_rate cash -= amount + fee total_fees += fee qty += amount / price elif action == "sell" and qty > 0: planned = _planned_order_krw(t, order, sizing_mode) if planned >= qty * price * 0.999: sell_qty = qty else: sell_qty = min(qty, planned / price) if sell_qty <= 0: continue gross = sell_qty * price fee = gross * fee_rate cash += gross - fee total_fees += fee qty -= sell_qty if qty < 1e-12: qty = 0.0 mark_price = float(last_price if last_price is not None else last_trade_price or 0) holding_value = qty * mark_price final_asset = cash + holding_value pnl_krw = final_asset - initial_cash pnl_pct = pnl_krw / initial_cash * 100.0 if initial_cash else 0.0 return { "initial_cash_krw": round(initial_cash, 0), "final_asset_krw": round(final_asset, 0), "pnl_krw": round(pnl_krw, 0), "pnl_pct": round(pnl_pct, 2), "total_fees_krw": round(total_fees, 0), "cash_krw": round(cash, 0), "holding_qty": round(qty, 6), "holding_value_krw": round(holding_value, 0), "mark_price": round(mark_price, 2), "fee_rate": fee_rate, "order_krw": round(order, 0), "sizing_mode": sizing_mode, "trade_count": len(trades), } def simulate_fixed_order_portfolio_steps( trades: list[dict[str, Any]], order_krw: float = LIVE_ORDER_KRW, initial_cash: float = GT_INITIAL_CASH_KRW, fee_rate: float = TRADING_FEE_RATE, sizing_mode: str = "fixed", ) -> list[dict[str, Any]]: """ 체결마다 현금·보유·총평가 스냅샷 (GT 테이블용). Args: trades: 시간순 trade dict. order_krw: 1회 체결 원화. initial_cash: 시작 현금. fee_rate: 수수료율. sizing_mode: fixed | amount_krw. Returns: step dict 리스트. """ cash = float(initial_cash) qty = 0.0 order = float(order_krw) steps: list[dict[str, Any]] = [] for t in sorted(trades, key=lambda x: x["dt"]): action = t["action"] price = float(t["price"]) if price <= 0: continue if action == "buy": planned = _planned_order_krw(t, order, sizing_mode) amount = min(planned, max(cash / (1.0 + fee_rate), 0.0)) if amount <= 0: continue fee = amount * fee_rate cash -= amount + fee qty += amount / price elif action == "sell" and qty > 0: planned = _planned_order_krw(t, order, sizing_mode) if planned >= qty * price * 0.999: sell_qty = qty else: sell_qty = min(qty, planned / price) if sell_qty <= 0: continue gross = sell_qty * price fee = gross * fee_rate cash += gross - fee qty -= sell_qty if qty < 1e-12: qty = 0.0 steps.append( { "dt": t["dt"], "action": action, "price": price, "rule_id": t.get("rule_id", ""), "forward_ret_pct": t.get("forward_ret_pct"), "amount_krw": t.get("amount_krw"), "cash_krw": round(cash, 0), "holding_qty": round(qty, 4), "total_asset_krw": round(cash + qty * price, 0), } ) return steps