인과적 GT 신호·복리 배분 시뮬을 도입하고 운영 정합성을 맞춘다.

미래 데이터를 쓰지 않는 causal 신호/tier와 전기간 복리 포트폴리오 비교로 GT 대비 sim_sized 검증 경로를 정리하고, 일한도·매수 상한·live_buy 스케일을 제거한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
xavis
2026-05-31 19:50:54 +09:00
parent 5842cc9fa3
commit e68bb44083
16 changed files with 1817 additions and 474 deletions

View File

@@ -1,5 +1,5 @@
"""
규칙 발화 기반 고정 금액 체결 포트폴리오 시뮬 (GT HTML 카드·테이블용).
규칙 발화 기반 GT 모델 복리 포트폴리오 시뮬.
"""
from __future__ import annotations
@@ -10,14 +10,13 @@ import pandas as pd
from config import (
GT_INITIAL_CASH_KRW,
LIVE_DAILY_KRW_MAX,
LIVE_MAX_TRADES_PER_DAY,
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_dynamic_buy_amounts,
load_sizing_context_from_gt,
attach_gt_model_amounts,
)
@@ -43,99 +42,95 @@ def _planned_order_krw(
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 (dt, side, close, rule_id …).
fires: fire_outcomes.
use_dynamic_sizing: 미사용 (하위 호환).
Returns:
체결된 발화 DataFrame.
정렬된 발화 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)
_ = 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]]:
"""
발화 DataFrame을 포트폴리오 시뮬용 trade dict 리스트로 변환.
발화 → GT 모델 amount_krw가 채워진 trade dict (복리 배분).
Args:
fires: 체결 대상 발화.
apply_dynamic_sizing: True면 GT tier 복리 배분.
initial_cash: 시작 현금 (누적 복리).
fee_rate: 수수료율.
Returns:
dt, action, price 키를 가진 dict 리스트.
dt, action, price, amount_krw 키 dict 리스트.
"""
if fires.empty:
return []
rows: list[dict[str, Any]] = []
for _, r in fires.sort_values("dt").iterrows():
for _, r in sort_fires_chronological(fires).iterrows():
rows.append(
{
"dt": str(r["dt"]),
@@ -145,13 +140,11 @@ def fires_to_trade_list(
"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(
if apply_dynamic_sizing:
attach_gt_model_amounts(
rows,
gt_trades=gt_trades,
approved_rules=approved,
large_legs=large_legs,
initial_cash=initial_cash,
fee_rate=fee_rate,
)
return rows
@@ -164,7 +157,7 @@ def simulate_sized_portfolio(
fallback_order_krw: float = LIVE_ORDER_KRW,
) -> dict[str, Any]:
"""
trade.amount_krw(총자산 비율 배분) 기준 포트폴리오 시뮬.
trade.amount_krw(GT 모델·복리 배분) 기준 포트폴리오 시뮬 + MDD.
Args:
trades: 시간순 trade dict (amount_krw 권장).
@@ -174,16 +167,25 @@ def simulate_sized_portfolio(
fallback_order_krw: amount_krw 없을 때 1회 금액.
Returns:
simulate_truth_portfolio와 동일 키 구조.
simulate_truth_portfolio와 동일 키 + max_drawdown_pct.
"""
return simulate_fixed_order_portfolio(
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,
order_krw=fallback_order_krw,
initial_cash=initial_cash,
fee_rate=fee_rate,
last_price=last_price,
sizing_mode="amount_krw",
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(
@@ -203,7 +205,7 @@ def simulate_fixed_order_portfolio(
initial_cash: 시작 현금.
fee_rate: 수수료율.
last_price: 미청산 평가 종가.
sizing_mode: 'fixed' | 'amount_krw' (없으면 order_krw).
sizing_mode: 'fixed' | 'amount_krw'.
Returns:
simulate_truth_portfolio와 동일 키 구조.