""" Ground Truth 타점·비중·자본 배분 모델 (일반화 명세). 타점 생성(ground_truth.py), 자본 배분(gt_allocation.py), 시뮬(position_sizing.py)의 공통 언어. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Callable from config import ( GT_BUY_BB_MAX, GT_BUY_MIN_BARS, GT_BUY_MIN_SWING_PCT, GT_BUY_PCT_LARGE_LEG, GT_BUY_PCT_SMALL_LEG, GT_BUY_WEIGHT_RULE, GT_LARGE_LEG_TOP_PCT, GT_MAX_BUYS_PER_LEG, GT_MAX_SELLS_PER_LEG, GT_MIN_ORDER_KRW, GT_SELL_SPLIT_GAP_PCT, GT_SELL_SPLIT_WEIGHTS, GT_SELECTION_MODE, ) # --- 매수 비중 규칙 (확장 가능) --- EntryWeightFn = Callable[[list[float]], list[float]] def normalize_weights(scores: list[float]) -> list[float]: """ 비중 점수를 합 1로 정규화합니다. Args: scores: raw score 리스트. Returns: 정규화 weight (합 ≈ 1). """ if not scores: return [] total = sum(scores) if total <= 0: n = len(scores) return [1.0 / n] * n return [s / total for s in scores] def compute_buy_weights_inverse_price(prices: list[float]) -> list[float]: """ 저점 매수 비중: score_i = 1/price_i → 합=1 정규화. Args: prices: leg 내 매수 후보 가격. Returns: weight 리스트 (합 ≈ 1). """ if not prices: return [] scores = [1.0 / max(p, 1e-9) for p in prices] return normalize_weights(scores) def compute_buy_weights_equal(_prices: list[float]) -> list[float]: """ 균등 분할 매수 비중. Args: _prices: leg 내 매수 후보 가격(미사용). Returns: weight 리스트 (합 = 1). """ n = len(_prices) if n <= 0: return [] return [1.0 / n] * n ENTRY_WEIGHT_RULES: dict[str, EntryWeightFn] = { "inverse_price_normalized": compute_buy_weights_inverse_price, "equal": compute_buy_weights_equal, } def compute_entry_weights( prices: list[float], rule: str | None = None, ) -> list[float]: """ 매수 타점 비중을 규칙명으로 계산합니다. Args: prices: 체결 가격 리스트. rule: `inverse_price_normalized` | `equal`. None이면 config. Returns: leg 내 매수 weight (합 ≈ 1). """ key = (rule or GT_BUY_WEIGHT_RULE).strip() fn = ENTRY_WEIGHT_RULES.get(key, compute_buy_weights_inverse_price) return fn(prices) def leg_entry_weights( prices: list[float], rule: str | None = None, ) -> list[float]: """ leg 내 매수 타점 비중 (compute_entry_weights 별칭). Args: prices: 체결 가격 리스트. rule: 비중 규칙 키. Returns: weight 리스트 (합 ≈ 1). """ return compute_entry_weights(prices, rule) def leg_exit_weights( n_sells: int, exit_spec: GtExitSpec | None = None, ) -> list[float]: """ leg 매도 분할 비중. Args: n_sells: 매도 횟수. exit_spec: 매도 명세. Returns: weight 리스트 (합 ≈ 1). """ return sell_split_weights(n_sells, exit_spec) @dataclass(frozen=True) class GtEntrySpec: """ 매수 타점(leg 내 분할) 규칙. Attributes: pivot_kind: ZigZag 저점(trough). price_field: 체결가 = 봉 Low. weight_rule: 매수 비중 규칙 키. max_per_leg: leg당 최대 매수 횟수. min_bars_gap: 분할 매수 최소 봉 간격. """ pivot_kind: str = "trough" price_field: str = "Low" weight_rule: str = GT_BUY_WEIGHT_RULE max_per_leg: int = GT_MAX_BUYS_PER_LEG min_bars_gap: int = GT_BUY_MIN_BARS bb_filter: str = f"bb_pos <= {GT_BUY_BB_MAX}" @dataclass(frozen=True) class GtExitSpec: """ 매도 타점(leg 내 분할) 규칙. Attributes: pivot_kind: major swing 고점(peak). price_field: 체결가 = 봉 High. split_weights: N회 분할 시 각 매도 비중 (합=1). split_gap_pct: 2차 고점 인정 최소 괴리(%). """ pivot_kind: str = "peak" price_field: str = "High" split_weights: tuple[float, ...] = GT_SELL_SPLIT_WEIGHTS split_gap_pct: float = GT_SELL_SPLIT_GAP_PCT max_per_leg: int = GT_MAX_SELLS_PER_LEG @dataclass(frozen=True) class GtCapitalSpec: """ 체결 원화(amount_krw) 배분 규칙. Attributes: buy_formula: 총자산 × 최적매수율, 상한=가용현금. optimal_buy_rate: leg 내 남은 weight 비중. large_leg_top_pct: 수익률 상위 leg 비율. pct_large: 상위 leg 총자산 배분 스케일. pct_small: 그 외 leg 스케일. min_order_krw: 최소 체결 원화. """ buy_formula: str = ( "target = total_asset * (weight/remaining_weights) * tier_scale; " "amount = min(target, available_cash/(1+fee))" ) optimal_buy_rate: str = "weight / sum(remaining_buy_weights_in_leg)" large_leg_top_pct: float = GT_LARGE_LEG_TOP_PCT pct_large: float = GT_BUY_PCT_LARGE_LEG pct_small: float = GT_BUY_PCT_SMALL_LEG min_order_krw: float = float(GT_MIN_ORDER_KRW) sell_formula: str = "sell_base_qty * sell_weight * price (last sell = full leg_qty)" @dataclass class GroundTruthModel: """ GT 전체 모델 (타점 + 비중 + 자본). Attributes: selection_mode: 타점 생성 모드. entry: 매수 명세. exit: 매도 명세. capital: 자본 배분 명세. leg: leg 정의. """ selection_mode: str = GT_SELECTION_MODE entry: GtEntrySpec = field(default_factory=GtEntrySpec) exit: GtExitSpec = field(default_factory=GtExitSpec) capital: GtCapitalSpec = field(default_factory=GtCapitalSpec) leg_definition: str = ( "이전 고점 매도 ~ 다음 고점 매도 구간 = leg_id; " "기간말 잔여 구간은 마지막 leg" ) execution_order_chrono: str = ( "amount_krw 배분·summary.pnl_pct = 시각순 체결(매도 후 현금 → 다음 매수 반영)" ) execution_order_leg_block: str = ( "JSON 저장 순서 = leg별 매수 전량 → 매도 전량 (차트·테이블 leg 정합)" ) def default_model() -> GroundTruthModel: """현재 config 기준 GT 모델.""" return GroundTruthModel() def sell_split_weights( n_sells: int, exit_spec: GtExitSpec | None = None, ) -> list[float]: """ leg 매도 비중 (1회=100%, N회=split_weights 정규화). Args: n_sells: 매도 횟수(1 이상). exit_spec: None이면 default. Returns: weight 리스트 (합 ≈ 1). """ spec = exit_spec or GtExitSpec() if n_sells <= 1: return [1.0] weights = list(spec.split_weights[:n_sells]) if len(weights) < n_sells: weights.extend([weights[-1]] * (n_sells - len(weights))) return normalize_weights(weights) def pair_peak_sell_weights( n_peaks: int, exit_spec: GtExitSpec | None = None, ) -> list[tuple[float, float]]: """ 고점 피벗 (피벗, weight) 쌍 — 1회 또는 분할. Args: n_peaks: 인정된 고점 수 (1 또는 2+). exit_spec: 매도 명세. Returns: (weight,) 또는 (w1, w2) 리스트. 호출측에서 피벗과 zip. """ if n_peaks <= 1: return [(1.0,)] w = sell_split_weights(2, exit_spec) return [(w[0],), (w[1],)] def remaining_weight_sum( trades: list[dict[str, Any]], leg_id: int, from_index: int, ) -> float: """ leg 내 from_index 이후 남은 매수 weight 합. Args: trades: 시각순 trade dict. leg_id: leg 번호. from_index: chron 리스트 인덱스. Returns: 남은 weight 합. """ total = 0.0 for j, t in enumerate(trades): if j < from_index: continue if int(t.get("leg_id", 0)) != leg_id: continue if t.get("action") == "buy": total += float(t.get("weight", 1.0)) return total def model_to_dict(model: GroundTruthModel | None = None) -> dict[str, Any]: """ JSON·리포트용 모델 dict. Args: model: None이면 default. Returns: 직렬화 dict. """ m = model or default_model() w_rule = m.entry.weight_rule w_formula = ( "w_i = (1/price_i) / sum(1/price_j)" if w_rule == "inverse_price_normalized" else "w_i = 1 / n" ) return { "selection_mode": m.selection_mode, "leg_definition": m.leg_definition, "entry": { "pivot": m.entry.pivot_kind, "price": m.entry.price_field, "weight_rule": m.entry.weight_rule, "weight_formula": w_formula, "max_buys_per_leg": m.entry.max_per_leg, "min_bars_between_buys": m.entry.min_bars_gap, "bb_filter": m.entry.bb_filter, }, "exit": { "pivot": m.exit.pivot_kind, "price": m.exit.price_field, "weight_rule": "fixed_split_or_full", "weights_split": list(m.exit.split_weights), "split_gap_pct": m.exit.split_gap_pct, "max_sells_per_leg": m.exit.max_per_leg, }, "capital": { "buy": m.capital.buy_formula, "optimal_buy_rate": m.capital.optimal_buy_rate, "large_leg_top_pct": m.capital.large_leg_top_pct, "pct_large_leg": m.capital.pct_large, "pct_small_leg": m.capital.pct_small, "min_order_krw": m.capital.min_order_krw, "sell": m.capital.sell_formula, }, "execution": { "chrono": m.execution_order_chrono, "leg_block_json": m.execution_order_leg_block, }, } def summarize_leg_weights(trades: list[dict[str, Any]]) -> dict[str, Any]: """ leg별 매수·매도 비중 합 검증용 요약. Args: trades: GT trade dict. Returns: leg_id → {buy_sum, sell_sum, n_buy, n_sell, valid}. """ legs: dict[int, dict[str, Any]] = {} for t in trades: lid = int(t.get("leg_id", 0)) legs.setdefault( lid, {"buy_sum": 0.0, "sell_sum": 0.0, "n_buy": 0, "n_sell": 0}, ) w = float(t.get("weight", 0)) if t.get("action") == "buy": legs[lid]["buy_sum"] += w legs[lid]["n_buy"] += 1 elif t.get("action") == "sell": legs[lid]["sell_sum"] += w legs[lid]["n_sell"] += 1 for lid, info in legs.items(): buy_ok = abs(info["buy_sum"] - 1.0) < 0.02 or info["n_buy"] == 0 sell_ok = abs(info["sell_sum"] - 1.0) < 0.02 or info["n_sell"] == 0 info["valid"] = buy_ok and sell_ok info["buy_sum"] = round(info["buy_sum"], 4) info["sell_sum"] = round(info["sell_sum"], 4) return legs def weight_policy_summary(model: GroundTruthModel | None = None) -> dict[str, Any]: """ 시뮬·리포트용 비중 정책 요약. Args: model: GT 모델. Returns: entry/exit/capital 요약 dict. """ m = model or default_model() return { "entry_weight_rule": m.entry.weight_rule, "exit_split_weights": list(m.exit.split_weights), "capital_large_pct": m.capital.pct_large, "capital_small_pct": m.capital.pct_small, "large_leg_top_pct": m.capital.large_leg_top_pct, }