인과적 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

@@ -0,0 +1,402 @@
"""
GT 공통 자본 배분·포트폴리오 시뮬 엔진.
ground_truth.allocate_gt_order_amounts · simulate_truth_portfolio ·
matching/portfolio_sim 이 동일 규칙을 공유합니다.
"""
from __future__ import annotations
from typing import Any, Callable
from config import (
GT_INITIAL_CASH_KRW,
GT_MIN_ORDER_KRW,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.gt_model import remaining_weight_sum
def resolve_sell_qty(
t: dict[str, Any],
qty: float,
price: float,
sell_base_qty: float,
weight: float,
) -> float:
"""
매도 수량: amount_krw 우선, 없으면 sell_base_qty × weight.
Args:
t: trade dict.
qty: 현재 보유 수량.
price: 체결가.
sell_base_qty: leg 첫 매도 시점 보유량.
weight: 매도 비중.
Returns:
매도 수량.
"""
if qty <= 0 or price <= 0:
return 0.0
ak = t.get("amount_krw")
if ak is not None and float(ak) > 0:
gross_cap = float(ak)
if gross_cap >= qty * price * 0.999:
return qty
return min(qty, gross_cap / price)
return min(sell_base_qty * weight, qty)
def allocate_order_amounts_chronological(
trades: list[dict[str, Any]],
*,
initial_cash: float = GT_INITIAL_CASH_KRW,
min_order_krw: float = GT_MIN_ORDER_KRW,
fee_rate: float = TRADING_FEE_RATE,
large_legs: set[int] | None = None,
asset_pct_scale_fn: Callable[[dict[str, Any]], float] | None = None,
causal_tier: bool = False,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""
시각순·leg 비중·티어 스케일로 amount_krw를 배분합니다.
causal_tier=True: 청산 완료 leg의 realized return 만으로 tier 산정 (인과적).
Args:
trades: trade dict (weight·leg_id·action·price).
initial_cash: 초기 현금.
min_order_krw: 최소 체결 원화.
fee_rate: 수수료율.
large_legs: 대형 leg. None이면 GT trades에서 산출(비인과).
asset_pct_scale_fn: 매수 trade별 tier scale.
causal_tier: 과거 청산 leg 수익률만으로 tier.
Returns:
(amount_krw 채워진 trades, alloc_stats).
"""
from config import GT_LARGE_LEG_TOP_PCT
from deepcoin.matching.position_sizing import (
compute_buy_amount_krw,
large_leg_ids_from_past_returns,
leg_asset_pct_scale,
top_leg_ids_by_forward_return,
)
chron = sorted(trades, key=lambda x: x["dt"])
if large_legs is None and not causal_tier:
large_legs = top_leg_ids_by_forward_return(chron)
elif large_legs is None:
large_legs = set()
leg_buy_idxs: dict[int, list[int]] = {}
leg_sell_idxs: dict[int, list[int]] = {}
for i, t in enumerate(chron):
lid = int(t.get("leg_id", 0))
if t["action"] == "buy":
leg_buy_idxs.setdefault(lid, []).append(i)
elif t["action"] == "sell":
leg_sell_idxs.setdefault(lid, []).append(i)
cash = float(initial_cash)
qty = 0.0
qty_by_leg: dict[int, float] = {}
sell_leg: int | None = None
sell_base_qty = 0.0
buy_executed = 0
buy_skipped = 0
sell_executed = 0
sell_skipped = 0
buy_amounts: list[float] = []
completed_leg_ret: dict[int, float] = {}
leg_cost_krw: dict[int, float] = {}
leg_proceeds_krw: dict[int, float] = {}
for i, t in enumerate(chron):
price = float(t["price"])
if price <= 0:
continue
leg_id = int(t.get("leg_id", 0))
weight = float(t.get("weight", 1.0))
if t["action"] == "buy":
w_sum = remaining_weight_sum(chron, leg_id, i)
if causal_tier:
large_now = large_leg_ids_from_past_returns(
completed_leg_ret, GT_LARGE_LEG_TOP_PCT
)
scale = leg_asset_pct_scale(leg_id, large_now)
elif asset_pct_scale_fn is not None:
scale = asset_pct_scale_fn(t)
else:
scale = leg_asset_pct_scale(leg_id, large_legs)
amount = compute_buy_amount_krw(
cash,
qty,
price,
weight,
w_sum,
asset_pct_scale=scale,
min_order_krw=min_order_krw,
fee_rate=fee_rate,
)
if amount <= 0:
t["amount_krw"] = 0
buy_skipped += 1
continue
t["amount_krw"] = amount
fee = amount * fee_rate
cash -= amount + fee
bought_qty = amount / price
qty += bought_qty
qty_by_leg[leg_id] = qty_by_leg.get(leg_id, 0.0) + bought_qty
leg_cost_krw[leg_id] = leg_cost_krw.get(leg_id, 0.0) + amount + fee
buy_executed += 1
buy_amounts.append(amount)
sell_leg = None
elif t["action"] == "sell":
leg_qty = qty_by_leg.get(leg_id, 0.0)
if leg_qty <= 1e-12:
sell_skipped += 1
continue
if sell_leg != leg_id:
sell_leg = leg_id
sell_base_qty = leg_qty
rem_sells = [j for j in leg_sell_idxs.get(leg_id, []) if j >= i]
is_last_leg_sell = bool(rem_sells) and i == rem_sells[-1]
if is_last_leg_sell:
sell_qty = leg_qty
gross = sell_qty * price
else:
gross = sell_base_qty * weight * price
if gross >= min_order_krw:
gross = max(min_order_krw, gross)
gross = min(gross, leg_qty * price)
if gross <= 0:
sell_skipped += 1
continue
sell_qty = leg_qty if is_last_leg_sell else gross / price
t["amount_krw"] = round(gross, 0)
fee = gross * fee_rate
cash += gross - fee
leg_proceeds_krw[leg_id] = leg_proceeds_krw.get(leg_id, 0.0) + (gross - fee)
leg_qty -= sell_qty
qty_by_leg[leg_id] = max(leg_qty, 0.0)
qty = max(qty - sell_qty, 0.0)
if qty < 1e-12:
qty = 0.0
sell_executed += 1
if causal_tier and leg_qty <= 1e-12:
cost = leg_cost_krw.pop(leg_id, 0.0)
proceeds = leg_proceeds_krw.pop(leg_id, 0.0)
if cost > 0:
completed_leg_ret[leg_id] = (proceeds - cost) / cost * 100.0
stats: dict[str, Any] = {
"buy_executed": buy_executed,
"buy_skipped": buy_skipped,
"sell_executed": sell_executed,
"sell_skipped": sell_skipped,
"buy_total_krw": round(sum(buy_amounts), 0),
"large_leg_count": len(large_legs),
}
if buy_amounts:
stats["buy_amount_avg_krw"] = round(sum(buy_amounts) / len(buy_amounts), 0)
stats["buy_amount_min_krw"] = round(min(buy_amounts), 0)
stats["buy_amount_max_krw"] = round(max(buy_amounts), 0)
return trades, stats
def simulate_portfolio_steps(
trades: list[dict[str, Any]],
*,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
use_amount_krw: bool = True,
) -> list[dict[str, Any]]:
"""
체결마다 현금·보유·총평가 스냅샷.
Args:
trades: 시각순 trade dict (amount_krw·weight·leg_id).
initial_cash: 시작 현금.
fee_rate: 수수료율.
use_amount_krw: True면 amount_krw 기준 체결.
Returns:
step dict 리스트.
"""
rows = sorted(trades, key=lambda x: x["dt"])
cash = float(initial_cash)
qty = 0.0
qty_by_leg: dict[int, float] = {}
sell_leg: int | None = None
sell_base_qty = 0.0
leg_budget = 0.0
current_leg: int | None = None
steps: list[dict[str, Any]] = []
for t in rows:
action = t.get("action", t.get("side", ""))
price = float(t["price"])
if price <= 0:
continue
weight = float(t.get("weight", 1.0))
leg_id = int(t.get("leg_id", 0))
if action == "buy":
if use_amount_krw and t.get("amount_krw") is not None and float(t["amount_krw"]) > 0:
amount = min(float(t["amount_krw"]), max(cash / (1.0 + fee_rate), 0.0))
else:
if leg_id != current_leg:
current_leg = leg_id
leg_budget = cash
amount = min(leg_budget * weight, max(cash / (1.0 + fee_rate), 0.0))
if amount <= 0:
continue
fee = amount * fee_rate
cash -= amount + fee
bought = amount / price
qty += bought
qty_by_leg[leg_id] = qty_by_leg.get(leg_id, 0.0) + bought
sell_leg = None
elif action == "sell" and qty > 0:
leg_qty = qty_by_leg.get(leg_id, qty)
if sell_leg != leg_id:
sell_leg = leg_id
sell_base_qty = leg_qty
sell_qty = resolve_sell_qty(t, leg_qty, price, sell_base_qty, weight)
if sell_qty <= 0:
continue
gross = sell_qty * price
fee = gross * fee_rate
cash += gross - fee
leg_qty -= sell_qty
qty_by_leg[leg_id] = max(leg_qty, 0.0)
qty -= sell_qty
if qty < 1e-12:
qty = 0.0
steps.append(
{
"dt": t["dt"],
"action": action,
"price": price,
"weight": weight,
"leg_id": leg_id,
"amount_krw": t.get("amount_krw"),
"cash_krw": round(cash, 0),
"holding_qty": round(qty, 6),
"total_asset_krw": round(cash + qty * price, 0),
}
)
return steps
def compute_drawdown_metrics(steps: list[dict[str, Any]]) -> dict[str, Any]:
"""
equity curve 기준 최대 낙폭·고점 대비 하락.
Args:
steps: simulate_portfolio_steps 결과.
Returns:
max_drawdown_pct, peak_asset_krw, trough_after_peak_krw.
"""
if not steps:
return {
"max_drawdown_pct": 0.0,
"peak_asset_krw": 0.0,
"trough_asset_krw": 0.0,
}
assets = [float(s["total_asset_krw"]) for s in steps]
peak = assets[0]
max_dd = 0.0
peak_at = assets[0]
trough_at = assets[0]
for a in assets:
if a > peak:
peak = a
dd = (peak - a) / peak * 100.0 if peak > 0 else 0.0
if dd > max_dd:
max_dd = dd
peak_at = peak
trough_at = a
return {
"max_drawdown_pct": round(max_dd, 2),
"peak_asset_krw": round(peak_at, 0),
"trough_asset_krw": round(trough_at, 0),
}
def simulate_portfolio_summary(
trades: list[dict[str, Any]],
*,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
last_price: float | None = None,
use_amount_krw: bool = True,
) -> dict[str, Any]:
"""
포트폴리오 시뮬 요약 + MDD.
Args:
trades: trade dict 리스트.
initial_cash: 시작 현금.
fee_rate: 수수료율.
last_price: 미청산 평가가.
use_amount_krw: amount_krw 체결 사용.
Returns:
pnl·fee·MDD 포함 dict.
"""
steps = simulate_portfolio_steps(
trades,
initial_cash=initial_cash,
fee_rate=fee_rate,
use_amount_krw=use_amount_krw,
)
if not steps:
return {
"initial_cash_krw": round(initial_cash, 0),
"final_asset_krw": round(initial_cash, 0),
"pnl_krw": 0.0,
"pnl_pct": 0.0,
"trade_count": len(trades),
"max_drawdown_pct": 0.0,
}
last_step = steps[-1]
cash = float(last_step["cash_krw"])
qty = float(last_step["holding_qty"])
mark = float(last_price if last_price is not None else last_step["price"])
holding_value = qty * mark
final_asset = cash + holding_value
pnl = final_asset - initial_cash
pnl_pct = pnl / initial_cash * 100.0 if initial_cash else 0.0
fees = 0.0
for t in sorted(trades, key=lambda x: x["dt"]):
ak = float(t.get("amount_krw") or 0)
if ak <= 0:
continue
fees += ak * fee_rate
dd = compute_drawdown_metrics(steps)
return {
"initial_cash_krw": round(initial_cash, 0),
"final_asset_krw": round(final_asset, 0),
"pnl_krw": round(pnl, 0),
"pnl_pct": round(pnl_pct, 2),
"total_fees_krw": round(fees, 0),
"cash_krw": round(cash, 0),
"holding_qty": round(qty, 6),
"holding_value_krw": round(holding_value, 0),
"mark_price": round(mark, 2),
"fee_rate": fee_rate,
"trade_count": len(trades),
**dd,
}