Files
Bithumb/deepcoin/matching/position_sizing.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

607 lines
18 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.
"""
총자산 대비 GT 모델 매수율(비중) · 보유 현금 한도 · leg tier 배분.
"""
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,
MATCH_GT_TOLERANCE_MIN,
TRADING_FEE_RATE,
)
from deepcoin.matching.load_rules import load_matched_rules
from deepcoin.paths import MATCHING_FIRE_OUTCOMES, MATCHING_MATCHED_RULES
_GT_ALLOC_ANALYSIS_CACHE: dict[str, Any] | None = None
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), 체결=min(목표, 보유현금/(1+fee)) 로 매수 원화를 산출합니다.
보유 현금 = 총보유자산 코인평가액(cash 인자).
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, _, available_cash = portfolio_totals(cash, qty, price)
budget = max(available_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 large_leg_ids_from_past_returns(
leg_returns: dict[int, float],
top_pct: float = GT_LARGE_LEG_TOP_PCT,
) -> set[int]:
"""
이미 청산된 leg의 realized return 상위 n% (인과적 tier).
Args:
leg_returns: leg_id → realized return %.
top_pct: 상위 비율.
Returns:
large leg id set.
"""
if not leg_returns:
return set()
ranked = sorted(leg_returns.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 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 config import SIM_FEE_STRESS_MULT
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 load_gt_allocation_analysis(
gt_trades: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""
GT amount_krw 분석 캐시 (tier 권장 pct).
Args:
gt_trades: GT trades. None이면 파일 로드.
Returns:
analyze_gt_buy_allocation 결과.
"""
global _GT_ALLOC_ANALYSIS_CACHE
if _GT_ALLOC_ANALYSIS_CACHE is not None:
return _GT_ALLOC_ANALYSIS_CACHE
from deepcoin.ground_truth.gt_allocation_analysis import analyze_gt_buy_allocation
from deepcoin.paths import resolve_ground_truth_file
trades = gt_trades
if trades is None:
p = resolve_ground_truth_file()
if p.is_file():
trades = json.loads(p.read_text(encoding="utf-8")).get("trades") or []
if not trades:
_GT_ALLOC_ANALYSIS_CACHE = {}
return _GT_ALLOC_ANALYSIS_CACHE
chron = sorted(trades, key=lambda x: x["dt"])
if not any(float(t.get("amount_krw") or 0) > 0 for t in chron):
from deepcoin.ground_truth.ground_truth import allocate_gt_order_amounts
allocate_gt_order_amounts(chron)
_GT_ALLOC_ANALYSIS_CACHE = analyze_gt_buy_allocation(chron)
return _GT_ALLOC_ANALYSIS_CACHE
def gt_tier_scale_for_trade(
trade: dict[str, Any],
gt_trades: list[dict[str, Any]],
large_legs: set[int],
*,
analysis: dict[str, Any] | None = None,
) -> float:
"""
GT leg tier 배분 스케일 (분석 권장값 또는 config).
시뮬은 live_buy_asset_pct_scale 대신 GT와 동일 tier 정책을 사용합니다.
Args:
trade: {dt, leg_id?, action, ...}.
gt_trades: GT trades (leg 매칭).
large_legs: 상위 leg.
analysis: analyze_gt_buy_allocation 결과.
Returns:
pct_large 또는 pct_small.
"""
from deepcoin.ground_truth.gt_allocation_analysis import gt_tier_scale_from_analysis
lid = trade.get("leg_id")
if lid is None:
lid = nearest_gt_leg_id(str(trade["dt"]), gt_trades)
if lid is None:
return float(GT_BUY_PCT_SMALL_LEG)
return gt_tier_scale_from_analysis(int(lid), large_legs, analysis)
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:
"""
실거래 전용 매수 tier (EV/WF·leg 상위). 시뮬은 gt_tier_scale_for_trade 사용.
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에 가까운 소형).
"""
from config import LIVE_BUY_PCT_LARGE, LIVE_BUY_PCT_SMALL
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 enrich_sim_trades_with_gt_weights(
trades: list[dict[str, Any]],
gt_trades: list[dict[str, Any]],
*,
causal_legs: bool = False,
) -> list[dict[str, Any]]:
"""
규칙 발화에 GT leg_id·매수/매도 weight를 부여합니다.
causal_legs=True: GT leg 매칭 없이 매수~매도 구간 순번 leg_id (인과적).
Args:
trades: {dt, action/side, price, rule_id}.
gt_trades: GT trades (leg 매칭, causal_legs=False 일 때).
causal_legs: 순차 leg_id.
Returns:
leg_id·weight가 채워진 trade dict.
"""
from deepcoin.ground_truth.gt_model import leg_entry_weights, leg_exit_weights
rows = sorted(trades, key=lambda x: x["dt"])
pos = 0
seq_leg = 0
while pos < len(rows):
action = rows[pos].get("action", rows[pos].get("side", ""))
if action != "buy":
if causal_legs:
rows[pos]["leg_id"] = seq_leg
elif "leg_id" not in rows[pos]:
rows[pos]["leg_id"] = nearest_gt_leg_id(rows[pos]["dt"], gt_trades) or 0
rows[pos]["weight"] = float(rows[pos].get("weight", 1.0))
pos += 1
continue
buy_end = pos
while buy_end < len(rows):
a = rows[buy_end].get("action", rows[buy_end].get("side", ""))
if a != "buy":
break
buy_end += 1
buy_slice = rows[pos:buy_end]
sell_slice: list[dict[str, Any]] = []
sell_end = buy_end
while sell_end < len(rows):
a = rows[sell_end].get("action", rows[sell_end].get("side", ""))
if a == "buy":
break
if a == "sell":
sell_slice.append(rows[sell_end])
sell_end += 1
if causal_legs:
leg_id = seq_leg
else:
leg_id = nearest_gt_leg_id(buy_slice[0]["dt"], gt_trades) or 0
prices = [float(t["price"]) for t in buy_slice]
buy_weights = leg_entry_weights(prices)
for t, w in zip(buy_slice, buy_weights):
t["leg_id"] = leg_id
t["weight"] = round(w, 4)
if "action" not in t and "side" in t:
t["action"] = t["side"]
if sell_slice:
sw = leg_exit_weights(len(sell_slice))
for t, w in zip(sell_slice, sw):
t["leg_id"] = leg_id
t["weight"] = round(w, 4)
if "action" not in t and "side" in t:
t["action"] = t["side"]
if causal_legs and sell_slice:
seq_leg += 1
pos = sell_end if sell_slice else buy_end
return rows
def attach_gt_model_amounts(
trades: list[dict[str, Any]],
*,
gt_trades: list[dict[str, Any]] | None = None,
approved_rules: set[str] | None = None,
large_legs: set[int] | None = None,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
) -> list[dict[str, Any]]:
"""
GT 모델 비중 + 공통 배분 엔진으로 amount_krw를 채웁니다.
시뮬·매칭 전용: leg·tier 모두 인과적(과거 청산 leg 수익만). GT 정답 배분은
ground_truth.allocate_gt_order_amounts 를 사용하세요.
Args:
trades: enrich_sim_trades_with_gt_weights 출력 또는 raw fires.
gt_trades: GT trades. None이면 파일 로드.
approved_rules: EV/WF 통과 rule (live scale용).
large_legs: 상위 leg.
initial_cash: 초기 현금.
fee_rate: 수수료율.
Returns:
amount_krw·weight·leg_id가 채워진 trade dict.
"""
from deepcoin.ground_truth.gt_allocation import allocate_order_amounts_chronological
if gt_trades is None:
gt_trades, _, _ = load_sizing_context_from_gt()
enriched = enrich_sim_trades_with_gt_weights(
list(trades),
gt_trades,
causal_legs=True,
)
allocate_order_amounts_chronological(
enriched,
initial_cash=initial_cash,
fee_rate=fee_rate,
large_legs=None,
asset_pct_scale_fn=None,
causal_tier=True,
)
return enriched
def plan_open_position_buy(
open_buys: list[dict[str, Any]],
candidate: dict[str, Any],
cash: float,
qty: float,
gt_trades: list[dict[str, Any]] | None = None,
*,
large_legs: set[int],
analysis: dict[str, Any] | None = None,
fee_rate: float = TRADING_FEE_RATE,
) -> float:
"""
미청산 포지션 내 다음 매수 원화 (GT tier·보유 현금 한도, 1회 상한 없음).
Args:
open_buys: 현재 포지션에서 이미 체결된 매수 dict.
candidate: 이번 매수 후보 {dt, price, rule_id, leg_id?, ...}.
cash: 보유 현금.
qty: 보유 수량.
gt_trades: GT leg 매칭용.
large_legs: 상위 leg.
analysis: GT 배분 분석.
fee_rate: 수수료율.
Returns:
매수 계획 원화.
"""
from deepcoin.ground_truth.gt_model import leg_entry_weights
if gt_trades is None:
gt_trades, _, _ = load_sizing_context_from_gt()
if analysis is None:
analysis = load_gt_allocation_analysis(gt_trades)
prices = [float(t["price"]) for t in open_buys] + [float(candidate["price"])]
weights = leg_entry_weights(prices)
idx = len(open_buys)
w = weights[idx]
w_sum = sum(weights[idx:])
cand = dict(candidate)
if "leg_id" not in cand:
cand["leg_id"] = nearest_gt_leg_id(str(candidate["dt"]), gt_trades)
scale = gt_tier_scale_for_trade(
cand,
gt_trades,
large_legs,
analysis=analysis,
)
return compute_buy_amount_krw(
cash,
qty,
float(candidate["price"]),
w,
w_sum,
asset_pct_scale=scale,
fee_rate=fee_rate,
)
def attach_dynamic_buy_amounts(
trades: list[dict[str, Any]],
*,
gt_trades: list[dict[str, Any]] | None = None,
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(GT 모델·보유 현금 한도)를 채웁니다.
attach_gt_model_amounts 별칭.
"""
return attach_gt_model_amounts(
trades,
gt_trades=gt_trades,
approved_rules=approved_rules,
large_legs=large_legs,
initial_cash=initial_cash,
fee_rate=fee_rate,
)
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)
return trades, large, set()