인과적 GT 신호·복리 배분 시뮬을 도입하고 운영 정합성을 맞춘다.
미래 데이터를 쓰지 않는 causal 신호/tier와 전기간 복리 포트폴리오 비교로 GT 대비 sim_sized 검증 경로를 정리하고, 일한도·매수 상한·live_buy 스케일을 제거한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
"""
|
||||
Ground Truth 타점·비중·자본 배분 모델 (일반화 명세).
|
||||
|
||||
타점 생성(ground_truth.py)과 자본 배분(position_sizing.py)의 공통 언어.
|
||||
타점 생성(ground_truth.py), 자본 배분(gt_allocation.py),
|
||||
시뮬(position_sizing.py)의 공통 언어.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
from config import (
|
||||
GT_BUY_BB_MAX,
|
||||
@@ -15,14 +16,130 @@ from config import (
|
||||
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:
|
||||
@@ -32,14 +149,14 @@ class GtEntrySpec:
|
||||
Attributes:
|
||||
pivot_kind: ZigZag 저점(trough).
|
||||
price_field: 체결가 = 봉 Low.
|
||||
weight_rule: 저가일수록 큰 비중 (1/price 정규화).
|
||||
weight_rule: 매수 비중 규칙 키.
|
||||
max_per_leg: leg당 최대 매수 횟수.
|
||||
min_bars_gap: 분할 매수 최소 봉 간격.
|
||||
"""
|
||||
|
||||
pivot_kind: str = "trough"
|
||||
price_field: str = "Low"
|
||||
weight_rule: str = "inverse_price_normalized"
|
||||
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}"
|
||||
@@ -53,13 +170,13 @@ class GtExitSpec:
|
||||
Attributes:
|
||||
pivot_kind: major swing 고점(peak).
|
||||
price_field: 체결가 = 봉 High.
|
||||
split_weights: 2회 분할 시 (65%, 35%).
|
||||
split_weights: N회 분할 시 각 매도 비중 (합=1).
|
||||
split_gap_pct: 2차 고점 인정 최소 괴리(%).
|
||||
"""
|
||||
|
||||
pivot_kind: str = "peak"
|
||||
price_field: str = "High"
|
||||
split_weights: tuple[float, float] = (0.65, 0.35)
|
||||
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
|
||||
|
||||
@@ -78,13 +195,16 @@ class GtCapitalSpec:
|
||||
min_order_krw: 최소 체결 원화.
|
||||
"""
|
||||
|
||||
buy_formula: str = "min(total_asset * w_share * tier_scale, cash/(1+fee))"
|
||||
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 = "leg_qty * sell_weight * price (last sell = full leg_qty)"
|
||||
sell_formula: str = "sell_base_qty * sell_weight * price (last sell = full leg_qty)"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -121,37 +241,74 @@ def default_model() -> GroundTruthModel:
|
||||
return GroundTruthModel()
|
||||
|
||||
|
||||
def compute_buy_weights_inverse_price(prices: list[float]) -> list[float]:
|
||||
def sell_split_weights(
|
||||
n_sells: int,
|
||||
exit_spec: GtExitSpec | None = None,
|
||||
) -> list[float]:
|
||||
"""
|
||||
저점 매수 비중: score_i = 1/price_i → 합=1 정규화.
|
||||
leg 매도 비중 (1회=100%, N회=split_weights 정규화).
|
||||
|
||||
Args:
|
||||
prices: leg 내 매수 후보 가격.
|
||||
n_sells: 매도 횟수(1 이상).
|
||||
exit_spec: None이면 default.
|
||||
|
||||
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)
|
||||
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 sell_split_weights(n_sells: int) -> list[float]:
|
||||
def pair_peak_sell_weights(
|
||||
n_peaks: int,
|
||||
exit_spec: GtExitSpec | None = None,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""
|
||||
leg 매도 비중.
|
||||
고점 피벗 (피벗, weight) 쌍 — 1회 또는 분할.
|
||||
|
||||
Args:
|
||||
n_sells: 매도 횟수(1 또는 2).
|
||||
n_peaks: 인정된 고점 수 (1 또는 2+).
|
||||
exit_spec: 매도 명세.
|
||||
|
||||
Returns:
|
||||
weight 리스트.
|
||||
(weight,) 또는 (w1, w2) 리스트. 호출측에서 피벗과 zip.
|
||||
"""
|
||||
spec = GtExitSpec()
|
||||
if n_sells >= 2:
|
||||
return list(spec.split_weights)
|
||||
return [1.0]
|
||||
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]:
|
||||
@@ -165,6 +322,12 @@ def model_to_dict(model: GroundTruthModel | None = None) -> dict[str, Any]:
|
||||
직렬화 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,
|
||||
@@ -172,7 +335,7 @@ def model_to_dict(model: GroundTruthModel | None = None) -> dict[str, Any]:
|
||||
"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)",
|
||||
"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,
|
||||
@@ -181,7 +344,7 @@ def model_to_dict(model: GroundTruthModel | None = None) -> dict[str, Any]:
|
||||
"pivot": m.exit.pivot_kind,
|
||||
"price": m.exit.price_field,
|
||||
"weight_rule": "fixed_split_or_full",
|
||||
"weights_two_sell": list(m.exit.split_weights),
|
||||
"weights_split": list(m.exit.split_weights),
|
||||
"split_gap_pct": m.exit.split_gap_pct,
|
||||
"max_sells_per_leg": m.exit.max_per_leg,
|
||||
},
|
||||
@@ -209,7 +372,7 @@ def summarize_leg_weights(trades: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
trades: GT trade dict.
|
||||
|
||||
Returns:
|
||||
leg_id → {buy_sum, sell_sum, n_buy, n_sell}.
|
||||
leg_id → {buy_sum, sell_sum, n_buy, n_sell, valid}.
|
||||
"""
|
||||
legs: dict[int, dict[str, Any]] = {}
|
||||
for t in trades:
|
||||
@@ -225,4 +388,30 @@ def summarize_leg_weights(trades: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user