""" 총자산 대비 최적 매수율(비중) · 현금 한도 · 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