GT 총자산 비율 매수·leg 티어 배분과 시뮬/실거래 포지션 사이징을 통합한다.

타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를
position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 16:11:49 +09:00
parent 2cb67c42b3
commit 5842cc9fa3
14 changed files with 2073 additions and 182 deletions

View File

@@ -15,9 +15,39 @@ from config import (
LIVE_ORDER_KRW,
TRADING_FEE_RATE,
)
from deepcoin.matching.position_sizing import (
attach_dynamic_buy_amounts,
load_sizing_context_from_gt,
)
def select_capped_fires(fires: pd.DataFrame) -> pd.DataFrame:
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 select_capped_fires(
fires: pd.DataFrame,
*,
use_dynamic_sizing: bool = True,
) -> pd.DataFrame:
"""
일한도·회수 제한으로 체결 가능한 발화만 남깁니다.
@@ -29,20 +59,58 @@ def select_capped_fires(fires: pd.DataFrame) -> pd.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():
for idx, row in day_grp.iterrows():
if n_trades >= LIVE_MAX_TRADES_PER_DAY:
break
if spent + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX:
break
spent += LIVE_ORDER_KRW
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:
@@ -52,7 +120,11 @@ def select_capped_fires(fires: pd.DataFrame) -> pd.DataFrame:
return pd.concat(taken, ignore_index=True)
def fires_to_trade_list(fires: pd.DataFrame) -> list[dict[str, Any]]:
def fires_to_trade_list(
fires: pd.DataFrame,
*,
apply_dynamic_sizing: bool = True,
) -> list[dict[str, Any]]:
"""
발화 DataFrame을 포트폴리오 시뮬용 trade dict 리스트로 변환.
@@ -73,25 +145,65 @@ def fires_to_trade_list(fires: pd.DataFrame) -> list[dict[str, Any]]:
"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(
rows,
gt_trades=gt_trades,
approved_rules=approved,
large_legs=large_legs,
)
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(총자산 비율 배분) 기준 포트폴리오 시뮬.
Args:
trades: 시간순 trade dict (amount_krw 권장).
initial_cash: 시작 현금.
fee_rate: 수수료율.
last_price: 미청산 평가 종가.
fallback_order_krw: amount_krw 없을 때 1회 금액.
Returns:
simulate_truth_portfolio와 동일 키 구조.
"""
return simulate_fixed_order_portfolio(
trades,
order_krw=fallback_order_krw,
initial_cash=initial_cash,
fee_rate=fee_rate,
last_price=last_price,
sizing_mode="amount_krw",
)
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}.
order_krw: 1회 매수·매도 금액(원).
trades: 시간순 {dt, action, price, amount_krw?}.
order_krw: sizing_mode=fixed 일 때 1회 금액(원).
initial_cash: 시작 현금.
fee_rate: 수수료율.
last_price: 미청산 평가 종가.
sizing_mode: 'fixed' | 'amount_krw' (없으면 order_krw).
Returns:
simulate_truth_portfolio와 동일 키 구조.
@@ -110,7 +222,8 @@ def simulate_fixed_order_portfolio(
last_trade_price = price
if action == "buy":
amount = min(order, max(cash / (1.0 + fee_rate), 0.0))
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
@@ -119,7 +232,11 @@ def simulate_fixed_order_portfolio(
qty += amount / price
elif action == "sell" and qty > 0:
sell_qty = min(qty, order / price)
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
@@ -148,6 +265,7 @@ def simulate_fixed_order_portfolio(
"mark_price": round(mark_price, 2),
"fee_rate": fee_rate,
"order_krw": round(order, 0),
"sizing_mode": sizing_mode,
"trade_count": len(trades),
}
@@ -157,6 +275,7 @@ def simulate_fixed_order_portfolio_steps(
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 테이블용).
@@ -166,6 +285,7 @@ def simulate_fixed_order_portfolio_steps(
order_krw: 1회 체결 원화.
initial_cash: 시작 현금.
fee_rate: 수수료율.
sizing_mode: fixed | amount_krw.
Returns:
step dict 리스트.
@@ -182,7 +302,8 @@ def simulate_fixed_order_portfolio_steps(
continue
if action == "buy":
amount = min(order, max(cash / (1.0 + fee_rate), 0.0))
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
@@ -190,7 +311,11 @@ def simulate_fixed_order_portfolio_steps(
qty += amount / price
elif action == "sell" and qty > 0:
sell_qty = min(qty, order / price)
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
@@ -207,6 +332,7 @@ def simulate_fixed_order_portfolio_steps(
"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),

View File

@@ -0,0 +1,378 @@
"""
총자산 대비 최적 매수율(비중) · 현금 한도 · leg 상위·EV/WF 통과 대형 매수.
"""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from config import (
GT_BUY_PCT_LARGE_LEG,
GT_BUY_PCT_SMALL_LEG,
GT_INITIAL_CASH_KRW,
GT_LARGE_LEG_TOP_PCT,
GT_MIN_ORDER_KRW,
LIVE_BUY_PCT_LARGE,
LIVE_BUY_PCT_SMALL,
MATCH_GT_TOLERANCE_MIN,
SIM_FEE_STRESS_MULT,
SIM_GO_MIN_HOLDOUT_EV,
SIM_GO_MIN_HOLDOUT_PF,
SIM_GO_WF_POSITIVE_RATIO,
SIM_WALK_FORWARD_MIN_MONTHS,
TRADING_FEE_RATE,
)
from deepcoin.matching.load_rules import load_matched_rules
from deepcoin.paths import MATCHING_FIRE_OUTCOMES, MATCHING_MATCHED_RULES
def portfolio_totals(
cash: float,
qty: float,
price: float,
) -> tuple[float, float, float]:
"""
총보유자산·코인평가·가용현금(=총자산-평가액)을 계산합니다.
Args:
cash: 현금.
qty: 보유 수량.
price: 평가·체결가.
Returns:
(total_asset_krw, holding_value_krw, cash_krw).
"""
holding = qty * price
total = cash + holding
return total, holding, cash
def optimal_weight_share(weight: float, weight_sum_remaining: float) -> float:
"""
leg 내 남은 매수 비중 대비 이번 체결 최적 매수율(0~1).
Args:
weight: 이번 타점 weight.
weight_sum_remaining: 동일 leg 남은 매수 weight 합.
Returns:
비중 비율.
"""
if weight_sum_remaining > 0:
return weight / weight_sum_remaining
return 1.0
def compute_buy_amount_krw(
cash: float,
qty: float,
price: float,
weight: float,
weight_sum_remaining: float,
*,
asset_pct_scale: float,
min_order_krw: float = GT_MIN_ORDER_KRW,
fee_rate: float = TRADING_FEE_RATE,
) -> float:
"""
총자산 × (최적 매수율 × scale)을 목표로, 가용 현금을 넘지 않게 매수 원화를 산출합니다.
Args:
cash: 현금.
qty: 보유 수량.
price: 체결가.
weight: 타점 비중.
weight_sum_remaining: leg 내 남은 매수 weight 합.
asset_pct_scale: leg·규칙 티어(대형/소형) 스케일.
min_order_krw: 최소 주문 원화.
fee_rate: 수수료율.
Returns:
매수 원화(0이면 미체결).
"""
if price <= 0:
return 0.0
total_asset, _, _ = portfolio_totals(cash, qty, price)
budget = max(cash / (1.0 + fee_rate), 0.0)
opt_rate = optimal_weight_share(weight, weight_sum_remaining) * asset_pct_scale
target = total_asset * opt_rate
amount = min(target, budget)
if budget >= min_order_krw and 0 < amount < min_order_krw:
amount = min(min_order_krw, budget)
return round(max(amount, 0.0), 0)
def top_leg_ids_by_forward_return(
trades: list[dict[str, Any]],
top_pct: float = GT_LARGE_LEG_TOP_PCT,
) -> set[int]:
"""
leg별 최대 forward_return 기준 상위 n% leg_id 집합.
Args:
trades: GT trade dict.
top_pct: 상위 비율(0~1).
Returns:
대형 매수 leg_id set.
"""
leg_ret: dict[int, float] = {}
for t in trades:
if t.get("action") != "sell":
continue
lid = int(t.get("leg_id", 0))
ret = float(t.get("forward_return_pct") or 0.0)
leg_ret[lid] = max(leg_ret.get(lid, 0.0), ret)
if not leg_ret:
return set()
ranked = sorted(leg_ret.items(), key=lambda x: x[1], reverse=True)
n = max(1, int(len(ranked) * top_pct + 0.999999))
return {lid for lid, _ in ranked[:n]}
def leg_asset_pct_scale(leg_id: int, large_legs: set[int]) -> float:
"""
leg 티어에 따른 총자산 대비 매수 스케일.
Args:
leg_id: leg 번호.
large_legs: 상위 leg 집합.
Returns:
GT_BUY_PCT_LARGE_LEG 또는 GT_BUY_PCT_SMALL_LEG.
"""
if leg_id in large_legs:
return float(GT_BUY_PCT_LARGE_LEG)
return float(GT_BUY_PCT_SMALL_LEG)
def _parse_dt(dt: str) -> datetime:
return datetime.fromisoformat(str(dt).replace("Z", "+00:00")[:19])
def nearest_gt_leg_id(
dt: str,
gt_trades: list[dict[str, Any]],
tolerance_min: int = MATCH_GT_TOLERANCE_MIN,
) -> int | None:
"""
시각에 가장 가까운 GT trade의 leg_id (매수 우선).
Args:
dt: 발화 시각.
gt_trades: GT trades.
tolerance_min: 허용 분.
Returns:
leg_id 또는 None.
"""
if not gt_trades:
return None
t0 = _parse_dt(dt)
best_buy: int | None = None
best_buy_min = float(tolerance_min) + 1.0
best_any: int | None = None
best_any_min = float(tolerance_min) + 1.0
for t in gt_trades:
try:
t1 = _parse_dt(t["dt"])
except ValueError:
continue
delta = abs((t0 - t1).total_seconds()) / 60.0
if delta > tolerance_min:
continue
lid = int(t.get("leg_id", 0))
if t.get("action") == "buy" and delta < best_buy_min:
best_buy_min = delta
best_buy = lid
if delta < best_any_min:
best_any_min = delta
best_any = lid
return best_buy if best_buy is not None else best_any
_APPROVED_RULES_CACHE: set[str] | None = None
def load_ev_wf_approved_rule_ids(
matched_path: Path | None = None,
outcomes_path: Path | None = None,
) -> set[str]:
"""
holdout EV·PF, walk-forward, 수수료 스트레스를 모두 통과한 rule_id.
Args:
matched_path: matched_rules.json.
outcomes_path: fire_outcomes.csv.
Returns:
통과 rule_id set. 산출 불가 시 monitor_rules 전체 fallback.
"""
global _APPROVED_RULES_CACHE
if _APPROVED_RULES_CACHE is not None:
return set(_APPROVED_RULES_CACHE)
from deepcoin.matching.select_rules import _rule_metrics, _split_train_valid_holdout
from deepcoin.matching.simulation import (
evaluate_go_no_go,
simulate_live_order_cap,
walk_forward_by_month,
walk_forward_summary,
)
mp = matched_path or MATCHING_MATCHED_RULES
op = outcomes_path or MATCHING_FIRE_OUTCOMES
matched = load_matched_rules(mp)
rules = matched.get("monitor_rules") or []
if not rules or not op.is_file():
return {r["rule_id"] for r in rules}
import pandas as pd
from config import MATCH_FEE_RATE
outcomes = pd.read_csv(op)
outcomes["split"] = _split_train_valid_holdout(outcomes)
wf_sum = walk_forward_summary(walk_forward_by_month(outcomes))
fee_stress: dict[str, Any] = {}
for rid in outcomes["rule_id"].unique():
sub = outcomes[outcomes["rule_id"] == rid]
from deepcoin.matching.simulation import _fee_adjust_ret
adj = _fee_adjust_ret(sub["forward_ret_pct"], SIM_FEE_STRESS_MULT)
fee_stress[rid] = _rule_metrics(sub.assign(forward_ret_pct=adj))
monitor_ids = {r["rule_id"] for r in rules}
live_cap = simulate_live_order_cap(
outcomes, rule_ids=monitor_ids, holdout_only=True
)
go = evaluate_go_no_go(matched, wf_sum, fee_stress, live_cap)
passed = {c["rule_id"] for c in go.get("checks", []) if c.get("pass")}
if passed:
_APPROVED_RULES_CACHE = passed
return passed
fallback = monitor_ids
_APPROVED_RULES_CACHE = fallback
return fallback
def live_buy_asset_pct_scale(
rule_id: str,
dt: str,
gt_trades: list[dict[str, Any]],
*,
approved_rules: set[str],
large_legs: set[int],
) -> float:
"""
실거래·시뮬 매수: EV/WF 통과 규칙 + leg 상위만 대형 비율.
Args:
rule_id: 규칙 ID.
dt: 체결 시각.
gt_trades: GT trades.
approved_rules: 통과 rule_id.
large_legs: 상위 leg.
Returns:
LIVE_BUY_PCT_LARGE 또는 LIVE_BUY_PCT_SMALL(또는 0에 가까운 소형).
"""
if rule_id not in approved_rules:
return float(LIVE_BUY_PCT_SMALL)
lid = nearest_gt_leg_id(dt, gt_trades)
if lid is not None and lid in large_legs:
return float(LIVE_BUY_PCT_LARGE)
return float(LIVE_BUY_PCT_SMALL)
def attach_dynamic_buy_amounts(
trades: list[dict[str, Any]],
*,
gt_trades: list[dict[str, Any]],
approved_rules: set[str] | None = None,
large_legs: set[int] | None = None,
initial_cash: float = GT_INITIAL_CASH_KRW,
default_weight: float = 1.0,
fee_rate: float = TRADING_FEE_RATE,
) -> list[dict[str, Any]]:
"""
시뮬 발화 trade dict에 amount_krw(총자산 비율·현금 한도)를 채웁니다.
Args:
trades: 시간순 {dt, action, price, rule_id, …}.
gt_trades: GT leg 매칭용.
approved_rules: EV/WF 통과 rule. None이면 전 규칙 대형 허용 안 함.
large_legs: 상위 leg. None이면 GT에서 산출.
initial_cash: 초기 현금.
default_weight: 매수 weight 기본값.
fee_rate: 수수료율.
Returns:
amount_krw가 채워진 동일 리스트.
"""
if large_legs is None:
large_legs = top_leg_ids_by_forward_return(gt_trades)
if approved_rules is None:
approved_rules = set()
cash = float(initial_cash)
qty = 0.0
for t in sorted(trades, key=lambda x: x["dt"]):
action = t.get("action", t.get("side", ""))
price = float(t["price"])
if price <= 0:
continue
if action == "buy":
rid = str(t.get("rule_id", ""))
scale = live_buy_asset_pct_scale(
rid, t["dt"], gt_trades,
approved_rules=approved_rules,
large_legs=large_legs,
)
amount = compute_buy_amount_krw(
cash,
qty,
price,
float(t.get("weight", default_weight)),
float(t.get("weight", default_weight)),
asset_pct_scale=scale,
fee_rate=fee_rate,
)
t["amount_krw"] = amount
if amount > 0:
fee = amount * fee_rate
cash -= amount + fee
qty += amount / price
elif action == "sell" and qty > 1e-12:
gross = qty * price
t["amount_krw"] = round(gross, 0)
fee = gross * fee_rate
cash += gross - fee
qty = 0.0
return trades
def load_sizing_context_from_gt(
gt_path: Path | None = None,
) -> tuple[list[dict[str, Any]], set[int], set[str]]:
"""
GT JSON에서 trades, 상위 leg, EV/WF 통과 rule을 로드합니다.
Args:
gt_path: ground_truth_trades.json.
Returns:
(gt_trades, large_legs, approved_rules).
"""
from deepcoin.paths import resolve_ground_truth_file
p = gt_path or resolve_ground_truth_file()
trades: list[dict[str, Any]] = []
if p.is_file():
data = json.loads(p.read_text(encoding="utf-8"))
trades = data.get("trades") or []
large = top_leg_ids_by_forward_return(trades)
approved = load_ev_wf_approved_rule_ids()
return trades, large, approved

View File

@@ -12,6 +12,7 @@ import numpy as np
import pandas as pd
from config import (
GT_INITIAL_CASH_KRW,
LIVE_DAILY_KRW_MAX,
LIVE_MAX_TRADES_PER_DAY,
LIVE_ORDER_KRW,
@@ -28,7 +29,19 @@ from config import (
SIM_WALK_FORWARD_MIN_MONTHS,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.ground_truth import (
load_ground_truth,
order_trades_chronological,
simulate_truth_portfolio,
)
from deepcoin.matching.portfolio_sim import (
fires_to_trade_list,
select_capped_fires,
simulate_fixed_order_portfolio,
simulate_sized_portfolio,
)
from deepcoin.matching.select_rules import _rule_metrics, _split_train_valid_holdout
from deepcoin.paths import resolve_ground_truth_file
from deepcoin.paths import (
ANALYSIS_GT_CALIBRATION_JSON,
MATCHING_FIRE_OUTCOMES,
@@ -109,12 +122,19 @@ def walk_forward_summary(wf_rows: list[dict[str, Any]]) -> dict[str, Any]:
return out
def simulate_live_order_cap(outcomes: pd.DataFrame) -> dict[str, Any]:
def simulate_live_order_cap(
outcomes: pd.DataFrame,
*,
rule_ids: set[str] | None = None,
holdout_only: bool = True,
) -> dict[str, Any]:
"""
1회·일 한도·슬리피지 가정으로 체결 가능한 발화만 집계.
Args:
outcomes: fire_outcomes.
outcomes: fire_outcomes (split 컬럼 있으면 holdout 필터 가능).
rule_ids: None이면 전 규칙, 지정 시 해당 rule만.
holdout_only: True면 split==holdout 만.
Returns:
규칙별·전체 요약.
@@ -122,12 +142,27 @@ def simulate_live_order_cap(outcomes: pd.DataFrame) -> dict[str, Any]:
if outcomes.empty:
return {"rules": {}, "note": "발화 없음"}
df = outcomes.sort_values("dt").copy()
df = outcomes
if holdout_only and "split" in df.columns:
df = df[df["split"] == "holdout"]
if rule_ids is not None:
df = df[df["rule_id"].isin(rule_ids)]
df = df.sort_values("dt").copy()
df["ts"] = pd.to_datetime(df["dt"])
df["day"] = df["ts"].dt.date.astype(str)
slip = LIVE_SLIPPAGE_PCT
taken_rows: list[pd.DataFrame] = []
from deepcoin.matching.position_sizing import (
compute_buy_amount_krw,
live_buy_asset_pct_scale,
load_sizing_context_from_gt,
)
gt_trades, large_legs, approved = load_sizing_context_from_gt()
cash = float(GT_INITIAL_CASH_KRW)
qty = 0.0
for day, day_grp in df.groupby("day", sort=True):
spent = 0.0
n_trades = 0
@@ -135,9 +170,34 @@ def simulate_live_order_cap(outcomes: pd.DataFrame) -> dict[str, Any]:
for idx, row in day_grp.iterrows():
if n_trades >= LIVE_MAX_TRADES_PER_DAY:
break
if spent + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX:
break
spent += LIVE_ORDER_KRW
side = row["side"]
price = float(row["close"])
if side == "buy":
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 planned <= 0:
continue
if spent + planned > LIVE_DAILY_KRW_MAX:
break
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
taken_idx.append(idx)
if taken_idx:
@@ -164,6 +224,7 @@ def simulate_live_order_cap(outcomes: pd.DataFrame) -> dict[str, Any]:
"order_krw": LIVE_ORDER_KRW,
"daily_krw_max": LIVE_DAILY_KRW_MAX,
"slippage_pct": slip,
"sizing": "total_asset_pct_ev_wf_large_leg",
},
"taken_count": int(len(taken)),
"total_count": int(len(df)),
@@ -267,9 +328,35 @@ def build_simulation_report(
sub.assign(forward_ret_pct=adj)
)
live_cap = simulate_live_order_cap(outcomes)
monitor_ids = {r["rule_id"] for r in matched.get("monitor_rules", [])}
live_cap = simulate_live_order_cap(
outcomes, rule_ids=monitor_ids, holdout_only=True
)
go = evaluate_go_no_go(matched, wf_sum, fee_stress, live_cap)
portfolio_compare: dict[str, Any] = {}
gt_data = load_ground_truth(resolve_ground_truth_file()) or {}
gt_trades = gt_data.get("trades") or []
mark = (gt_data.get("summary") or {}).get("mark_price")
if gt_trades:
portfolio_compare["ground_truth_chrono"] = simulate_truth_portfolio(
order_trades_chronological(gt_trades),
last_price=float(mark) if mark else None,
)
holdout = outcomes[
outcomes["rule_id"].isin(monitor_ids) & (outcomes["split"] == "holdout")
]
capped = select_capped_fires(holdout)
if not capped.empty:
portfolio_compare["sim_sized"] = simulate_sized_portfolio(
fires_to_trade_list(capped, apply_dynamic_sizing=True),
last_price=float(mark) if mark else None,
)
portfolio_compare["sim_fixed_order"] = simulate_fixed_order_portfolio(
fires_to_trade_list(capped, apply_dynamic_sizing=False),
last_price=float(mark) if mark else None,
)
gt_portfolio: dict[str, Any] = {}
if ANALYSIS_GT_CALIBRATION_JSON.is_file():
cal = json.loads(ANALYSIS_GT_CALIBRATION_JSON.read_text(encoding="utf-8"))
@@ -301,6 +388,8 @@ def build_simulation_report(
"fee_stress_by_rule": fee_stress,
"live_order_cap_sim": live_cap,
"go_no_go": go,
"portfolio_compare": portfolio_compare,
"gt_model": gt_data.get("model"),
"monitor_rules": matched.get("monitor_rules", []),
"gt_portfolio_calibration": gt_portfolio,
"criteria": {

View File

@@ -20,6 +20,7 @@ from config import (
)
from deepcoin.ground_truth.ground_truth import (
load_ground_truth,
order_trades_chronological,
simulate_truth_portfolio,
simulate_truth_portfolio_steps,
)
@@ -28,6 +29,7 @@ from deepcoin.matching.portfolio_sim import (
select_capped_fires,
simulate_fixed_order_portfolio,
simulate_fixed_order_portfolio_steps,
simulate_sized_portfolio,
)
from deepcoin.matching.select_rules import _split_train_valid_holdout
from deepcoin.ops.chart_report import (
@@ -36,6 +38,7 @@ from deepcoin.ops.chart_report import (
market_cards_html,
pnl_cards_html,
rule_criteria_html,
stacked_summary_cards_html,
wrap_chart_report_page,
)
from deepcoin.ops.simulation import build_chart_html, load_chart_frames, _frames_to_mtf
@@ -194,9 +197,11 @@ def _summary_cards_html(
bb_txt: str,
gt_trades: list[dict[str, Any]],
gt_pnl: dict[str, Any],
sim_pnl: dict[str, Any],
sim_sized_pnl: dict[str, Any],
sim_fixed_pnl: dict[str, Any],
sim_trade_count: int,
go_flag: bool,
model_note: str = "",
) -> str:
"""
ground_truth HTML과 동일 구성의 상단 카드 (GT + 시뮬 2줄).
@@ -206,26 +211,43 @@ def _summary_cards_html(
bb_txt: BB %B.
gt_trades: GT trades.
gt_pnl: GT 포트폴리오 요약.
sim_pnl: 시뮬 포트폴리오 요약.
sim_sized_pnl: 총자산%·EV/WF·leg 시뮬 요약.
sim_fixed_pnl: 고정 ₩/회 baseline.
sim_trade_count: 체결 가정 발화 수.
go_flag: Go/No-Go.
model_note: GT 모델 한 줄 요약.
Returns:
cards HTML.
"""
go_cls = "go-pass" if go_flag else "go-fail"
gt_row = (
'<p class="cards-group-title">정답 (ground_truth) — 분할 비중·leg 체결</p>'
+ market_cards_html(close_last, bb_txt)
+ pnl_cards_html(gt_pnl, "정답 타점", len(gt_trades))
gt_sub = (
"저점 분할매수(1/price 비중) · 고점 65/35% 매도 · "
"총자산×비중×leg티어 · 시각순 복리"
)
sim_row = (
'<p class="cards-group-title">시뮬 (monitor_rules · holdout · '
f"1회 ₩{LIVE_ORDER_KRW:,.0f}·일한도) — "
f'<span class="{go_cls}">{"GO" if go_flag else "NO-GO"}</span></p>'
+ pnl_cards_html(sim_pnl, "시뮬 체결", sim_trade_count)
if model_note:
gt_sub = model_note
gt_cards = market_cards_html(close_last, bb_txt) + pnl_cards_html(
gt_pnl, "정답 GT", len(gt_trades)
)
sim_sized_title = (
"시뮬·총자산% (EV/WF·leg상위) — "
f'<span class="{go_cls}">{"GO" if go_flag else "NO-GO"}</span>'
)
sim_fixed_title = f"시뮬·고정 ₩{LIVE_ORDER_KRW:,}/회 (비교)"
return (
'<div class="summary-cards">'
+ stacked_summary_cards_html(gt_sub, gt_cards)
+ stacked_summary_cards_html(
sim_sized_title,
pnl_cards_html(sim_sized_pnl, "시뮬(비율)", sim_trade_count),
)
+ stacked_summary_cards_html(
sim_fixed_title,
pnl_cards_html(sim_fixed_pnl, "시뮬(고정)", sim_trade_count),
)
+ "</div>"
)
return gt_row + sim_row
def build_simulation_page_html(
@@ -294,56 +316,57 @@ def build_simulation_page_html(
elif gt_summary.get("mark_price"):
close_val = float(gt_summary["mark_price"])
sim_trades = fires_to_trade_list(capped)
gt_pnl = {}
gt_summary_pnl = gt_data.get("summary") or {}
if gt_summary_pnl.get("pnl_krw") is not None and gt_summary_pnl.get(
"execution_order"
) == "leg_block":
gt_pnl = {
k: gt_summary_pnl[k]
for k in (
"initial_cash_krw",
"final_asset_krw",
"pnl_pct",
"total_fees_krw",
"holding_qty",
"holding_value_krw",
"mark_price",
"cash_krw",
)
if k in gt_summary_pnl
}
elif gt_trades:
sim_trades_sized = fires_to_trade_list(capped, apply_dynamic_sizing=True)
sim_trades_fixed = fires_to_trade_list(capped, apply_dynamic_sizing=False)
gt_pnl: dict[str, Any] = {}
if gt_trades:
gt_chron = order_trades_chronological(gt_trades)
gt_pnl = simulate_truth_portfolio(
gt_trades,
gt_chron,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
last_price=close_val if close_val else None,
)
sim_pnl = simulate_fixed_order_portfolio(
sim_trades,
mark = close_val if close_val else None
sim_sized_pnl = simulate_sized_portfolio(
sim_trades_sized,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
last_price=mark,
)
sim_fixed_pnl = simulate_fixed_order_portfolio(
sim_trades_fixed,
order_krw=LIVE_ORDER_KRW,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
last_price=close_val if close_val else None,
last_price=mark,
sizing_mode="fixed",
)
sim_steps = simulate_fixed_order_portfolio_steps(
sim_trades,
sim_trades_sized,
order_krw=LIVE_ORDER_KRW,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
sizing_mode="amount_krw",
)
gt_steps = (
simulate_truth_portfolio_steps(
gt_trades,
order_trades_chronological(gt_trades),
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
)
if gt_trades
else []
)
model = gt_data.get("model") or {}
model_note = (
f"mode={model.get('selection_mode', 'split_buy_peak_sell')} · "
f"매수비중=1/price · 매도=65/35%"
if model
else ""
)
criteria_blocks = "".join(rule_criteria_html(r) for r in monitor_rules)
go_table = go_no_go_table_html(go.get("checks", []), go_flag)
@@ -355,7 +378,7 @@ def build_simulation_page_html(
sim_table = f"""
<h2>시뮬 타점 (holdout {len(holdout_fires)}건 → 체결 가정 {len(capped)}건)</h2>
<p class="meta">1회 ₩{LIVE_ORDER_KRW:,.0f}·일한도·최대 거래수 적용 후 체결 순 포트폴리오.
<p class="meta">총자산×최적비중·현금한도·EV/WF통과·leg상위 대형 매수. 일한도·최대 거래수 적용.
가격 열 (+/-) = <b>{label_mode}</b> 구간 수익%.{_mark_note(close_val)}</p>
<div class="table-scroll">
<table>
@@ -367,7 +390,7 @@ def build_simulation_page_html(
gt_table = f"""
<h2>정답 타점 (ground_truth)</h2>
<p class="meta">삼각형 = GT. 매수 분할 비중·매도 leg 반영.{_mark_note(close_val)}</p>
<p class="meta">삼각형 크기 = GT 체결 금액. 매수 분할·매도 leg 반영.{_mark_note(close_val)}</p>
<div class="table-scroll">
<table>
<thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead>
@@ -390,7 +413,7 @@ def build_simulation_page_html(
"상단 카드: 초기 금액·총보유자산·초기 대비 증감율·수수료."
)
legend = (
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 = GT 비중.<br>"
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 = GT 체결 금액.<br>"
"● <b>시뮬</b> — 원 = holdout 발화 (차트). 테이블 = 일한도 적용 체결 순서."
)
if frames is not None:
@@ -405,7 +428,15 @@ def build_simulation_page_html(
)
cards = _summary_cards_html(
close_val, bb_txt, gt_trades, gt_pnl, sim_pnl, len(capped), go_flag
close_val,
bb_txt,
gt_trades,
gt_pnl,
sim_sized_pnl,
sim_fixed_pnl,
len(capped),
go_flag,
model_note=model_note,
)
if frames is not None:
@@ -415,6 +446,7 @@ def build_simulation_page_html(
note=note,
truth_trades=gt_trades,
sim_trades=_fires_to_chart_trades(holdout_fires),
# 차트 마커는 holdout 전체; 카드·테이블은 일한도 capped
title_suffix="1단계 시뮬레이션 (monitor · holdout)",
legend_html=legend,
footer_sections=sections,