GT 총자산 비율 매수·leg 티어 배분과 시뮬/실거래 포지션 사이징을 통합한다.
타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를 position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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),
|
||||
|
||||
378
deepcoin/matching/position_sizing.py
Normal file
378
deepcoin/matching/position_sizing.py
Normal 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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user