타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를 position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
342 lines
10 KiB
Python
342 lines
10 KiB
Python
"""
|
|
규칙 발화 기반 고정 금액 체결 포트폴리오 시뮬 (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
|