미래 데이터를 쓰지 않는 causal 신호/tier와 전기간 복리 포트폴리오 비교로 GT 대비 sim_sized 검증 경로를 정리하고, 일한도·매수 상한·live_buy 스케일을 제거한다. Co-authored-by: Cursor <cursoragent@cursor.com>
403 lines
13 KiB
Python
403 lines
13 KiB
Python
"""
|
||
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,
|
||
}
|