Files
Bithumb/deepcoin/ground_truth/gt_allocation.py
xavis d385456867 hybrid DD tier와 Option C 2차(+1000%) 검증을 추가하고 실거래 사이징을 정합한다.
인과 GT leg 엔진·drawdown tier·train 캘리브레이션, Phase 2 Go/No-Go 및 시뮬 리포트를 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 16:09:28 +09:00

408 lines
13 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 공통 자본 배분·포트폴리오 시뮬 엔진.
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_BUY_PCT_LARGE_LEG, 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] = []
large_tier_buys = 0
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, completed_leg_ret)
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,
ignore_weight_split=bool(t.get("conviction_buy")),
)
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)
if scale >= float(GT_BUY_PCT_LARGE_LEG) * 0.99:
large_tier_buys += 1
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 or asset_pct_scale_fn is not None) 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": large_tier_buys,
"large_tier_buy_count": large_tier_buys,
}
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,
}