Files
Bithumb/deepcoin/matching/position_sizing.py
dsyoon 5842cc9fa3 GT 총자산 비율 매수·leg 티어 배분과 시뮬/실거래 포지션 사이징을 통합한다.
타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를
position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 16:11:49 +09:00

379 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.
"""
총자산 대비 최적 매수율(비중) · 현금 한도 · 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