타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를 position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
229 lines
6.6 KiB
Python
229 lines
6.6 KiB
Python
"""
|
||
Ground Truth 타점·비중·자본 배분 모델 (일반화 명세).
|
||
|
||
타점 생성(ground_truth.py)과 자본 배분(position_sizing.py)의 공통 언어.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
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_LARGE_LEG_TOP_PCT,
|
||
GT_MAX_BUYS_PER_LEG,
|
||
GT_MAX_SELLS_PER_LEG,
|
||
GT_MIN_ORDER_KRW,
|
||
GT_SELL_SPLIT_GAP_PCT,
|
||
GT_SELECTION_MODE,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class GtEntrySpec:
|
||
"""
|
||
매수 타점(leg 내 분할) 규칙.
|
||
|
||
Attributes:
|
||
pivot_kind: ZigZag 저점(trough).
|
||
price_field: 체결가 = 봉 Low.
|
||
weight_rule: 저가일수록 큰 비중 (1/price 정규화).
|
||
max_per_leg: leg당 최대 매수 횟수.
|
||
min_bars_gap: 분할 매수 최소 봉 간격.
|
||
"""
|
||
|
||
pivot_kind: str = "trough"
|
||
price_field: str = "Low"
|
||
weight_rule: str = "inverse_price_normalized"
|
||
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: 2회 분할 시 (65%, 35%).
|
||
split_gap_pct: 2차 고점 인정 최소 괴리(%).
|
||
"""
|
||
|
||
pivot_kind: str = "peak"
|
||
price_field: str = "High"
|
||
split_weights: tuple[float, float] = (0.65, 0.35)
|
||
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 = "min(total_asset * w_share * tier_scale, 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 = "leg_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 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]
|
||
s = sum(scores)
|
||
return [x / s for x in scores] if s > 0 else [1.0 / len(prices)] * len(prices)
|
||
|
||
|
||
def sell_split_weights(n_sells: int) -> list[float]:
|
||
"""
|
||
leg 매도 비중.
|
||
|
||
Args:
|
||
n_sells: 매도 횟수(1 또는 2).
|
||
|
||
Returns:
|
||
weight 리스트.
|
||
"""
|
||
spec = GtExitSpec()
|
||
if n_sells >= 2:
|
||
return list(spec.split_weights)
|
||
return [1.0]
|
||
|
||
|
||
def model_to_dict(model: GroundTruthModel | None = None) -> dict[str, Any]:
|
||
"""
|
||
JSON·리포트용 모델 dict.
|
||
|
||
Args:
|
||
model: None이면 default.
|
||
|
||
Returns:
|
||
직렬화 dict.
|
||
"""
|
||
m = model or default_model()
|
||
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_i = (1/price_i) / sum(1/price_j)",
|
||
"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_two_sell": 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}.
|
||
"""
|
||
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
|
||
return legs
|