타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를 position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
379 lines
11 KiB
Python
379 lines
11 KiB
Python
"""
|
||
총자산 대비 최적 매수율(비중) · 현금 한도 · 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
|