미래 데이터를 쓰지 않는 causal 신호/tier와 전기간 복리 포트폴리오 비교로 GT 대비 sim_sized 검증 경로를 정리하고, 일한도·매수 상한·live_buy 스케일을 제거한다. Co-authored-by: Cursor <cursoragent@cursor.com>
344 lines
9.8 KiB
Python
344 lines
9.8 KiB
Python
"""
|
||
규칙 발화 기반 GT 모델 복리 포트폴리오 시뮬.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
import pandas as pd
|
||
|
||
from config import (
|
||
GT_INITIAL_CASH_KRW,
|
||
GT_SIGNAL_CAUSAL,
|
||
LIVE_ORDER_KRW,
|
||
TRADING_FEE_RATE,
|
||
)
|
||
from deepcoin.ground_truth.gt_allocation import simulate_portfolio_summary
|
||
from deepcoin.matching.position_sizing import (
|
||
attach_gt_model_amounts,
|
||
)
|
||
|
||
|
||
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 sort_fires_chronological(fires: pd.DataFrame) -> pd.DataFrame:
|
||
"""
|
||
발화를 시간순 정렬 (일·금액 한도 없음).
|
||
|
||
Args:
|
||
fires: fire_outcomes.
|
||
|
||
Returns:
|
||
정렬된 DataFrame.
|
||
"""
|
||
if fires.empty:
|
||
return fires
|
||
return fires.sort_values("dt").copy()
|
||
|
||
|
||
def simulate_fires_compound(
|
||
fires: pd.DataFrame,
|
||
*,
|
||
initial_cash: float = GT_INITIAL_CASH_KRW,
|
||
fee_rate: float = TRADING_FEE_RATE,
|
||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||
"""
|
||
발화 → GT tier 복리 amount_krw 배분 (allocate_order_amounts_chronological).
|
||
|
||
Args:
|
||
fires: fire_outcomes.
|
||
initial_cash: 시작 현금 (이후 체결마다 누적).
|
||
fee_rate: 수수료율.
|
||
|
||
Returns:
|
||
(amount_krw 채워진 trade dict, stats).
|
||
"""
|
||
trades = fires_to_trade_list(
|
||
fires,
|
||
apply_dynamic_sizing=True,
|
||
initial_cash=initial_cash,
|
||
fee_rate=fee_rate,
|
||
)
|
||
n_in = int(len(fires))
|
||
n_out = sum(1 for t in trades if float(t.get("amount_krw") or 0) > 0)
|
||
return trades, {
|
||
"input_fires": n_in,
|
||
"executed": n_out,
|
||
"skipped": max(n_in - n_out, 0),
|
||
}
|
||
|
||
|
||
def select_capped_fires(
|
||
fires: pd.DataFrame,
|
||
*,
|
||
use_dynamic_sizing: bool = True,
|
||
) -> pd.DataFrame:
|
||
"""
|
||
시각순 발화 반환 (레거시명; 일·금액 한도 미적용).
|
||
|
||
Args:
|
||
fires: fire_outcomes.
|
||
use_dynamic_sizing: 미사용 (하위 호환).
|
||
|
||
Returns:
|
||
정렬된 발화 DataFrame.
|
||
"""
|
||
_ = use_dynamic_sizing
|
||
return sort_fires_chronological(fires)
|
||
|
||
|
||
def fires_to_trade_list(
|
||
fires: pd.DataFrame,
|
||
*,
|
||
apply_dynamic_sizing: bool = True,
|
||
initial_cash: float = GT_INITIAL_CASH_KRW,
|
||
fee_rate: float = TRADING_FEE_RATE,
|
||
) -> list[dict[str, Any]]:
|
||
"""
|
||
발화 → GT 모델 amount_krw가 채워진 trade dict (복리 배분).
|
||
|
||
Args:
|
||
fires: 체결 대상 발화.
|
||
apply_dynamic_sizing: True면 GT tier 복리 배분.
|
||
initial_cash: 시작 현금 (누적 복리).
|
||
fee_rate: 수수료율.
|
||
|
||
Returns:
|
||
dt, action, price, amount_krw 키 dict 리스트.
|
||
"""
|
||
if fires.empty:
|
||
return []
|
||
rows: list[dict[str, Any]] = []
|
||
for _, r in sort_fires_chronological(fires).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:
|
||
attach_gt_model_amounts(
|
||
rows,
|
||
initial_cash=initial_cash,
|
||
fee_rate=fee_rate,
|
||
)
|
||
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(GT 모델·복리 배분) 기준 포트폴리오 시뮬 + MDD.
|
||
|
||
Args:
|
||
trades: 시간순 trade dict (amount_krw 권장).
|
||
initial_cash: 시작 현금.
|
||
fee_rate: 수수료율.
|
||
last_price: 미청산 평가 종가.
|
||
fallback_order_krw: amount_krw 없을 때 1회 금액.
|
||
|
||
Returns:
|
||
simulate_truth_portfolio와 동일 키 + max_drawdown_pct.
|
||
"""
|
||
if trades and not any(float(t.get("amount_krw") or 0) > 0 for t in trades):
|
||
attach_gt_model_amounts(trades, initial_cash=initial_cash, fee_rate=fee_rate)
|
||
result = simulate_portfolio_summary(
|
||
trades,
|
||
initial_cash=initial_cash,
|
||
fee_rate=fee_rate,
|
||
last_price=last_price,
|
||
use_amount_krw=True,
|
||
)
|
||
result["sizing_mode"] = (
|
||
"gt_model_compound_causal" if GT_SIGNAL_CAUSAL else "gt_model_compound"
|
||
)
|
||
result["sizing_note"] = (
|
||
"전기간 복리·GT tier·총자산×비중, 보유현금 한도; "
|
||
+ ("인과적 신호·tier(미래 미사용)" if GT_SIGNAL_CAUSAL else "상한 없음")
|
||
)
|
||
return result
|
||
|
||
|
||
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'.
|
||
|
||
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
|