GT 총자산 비율 매수·leg 티어 배분과 시뮬/실거래 포지션 사이징을 통합한다.
타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를 position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
378
deepcoin/matching/position_sizing.py
Normal file
378
deepcoin/matching/position_sizing.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""
|
||||
총자산 대비 최적 매수율(비중) · 현금 한도 · 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
|
||||
Reference in New Issue
Block a user