Files
Bithumb/deepcoin/matching/portfolio_sim.py
xavis e68bb44083 인과적 GT 신호·복리 배분 시뮬을 도입하고 운영 정합성을 맞춘다.
미래 데이터를 쓰지 않는 causal 신호/tier와 전기간 복리 포트폴리오 비교로 GT 대비 sim_sized 검증 경로를 정리하고, 일한도·매수 상한·live_buy 스케일을 제거한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 19:50:54 +09:00

344 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
규칙 발화 기반 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