Files
Bithumb/deepcoin/ground_truth/gt_model.py
xavis e68bb44083 인과적 GT 신호·복리 배분 시뮬을 도입하고 운영 정합성을 맞춘다.
미래 데이터를 쓰지 않는 causal 신호/tier와 전기간 복리 포트폴리오 비교로 GT 대비 sim_sized 검증 경로를 정리하고, 일한도·매수 상한·live_buy 스케일을 제거한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 19:50:54 +09:00

418 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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,
}