refactor: GT·시뮬·운영 3축 정리 및 hybrid 실거래 정합

Phase C/dry-run·미사용 모듈·재생성 HTML을 제거하고, 운영 체결을
sim_causal_hybrid와 동일한 hybrid 로직으로 통합한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
xavis
2026-06-03 23:50:28 +09:00
parent a16c942be4
commit d7848df6f7
85 changed files with 177180 additions and 196131 deletions

View File

@@ -1,7 +1,7 @@
"""
시뮬 sim_causal_hybrid 와 동일 체결 엔진 (build_monitor_hybrid_sized_trades).
dry-run·live(06) 모두 발화 이력 → hybrid 배분 → amount_krw·수량 적용.
live(06) plan_live_hit: 발화 이력 → hybrid 배분 → amount_krw·수량 (인과, 현금·보유 제약).
"""
from __future__ import annotations
@@ -11,10 +11,17 @@ from typing import Any
import pandas as pd
from config import GT_INITIAL_CASH_KRW, TRADING_FEE_RATE
from config import (
CHART_LOOKBACK_DAYS,
GT_INITIAL_CASH_KRW,
LIVE_HYBRID_BOOTSTRAP_FIRES,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.causal_gt_hybrid import build_monitor_hybrid_sized_trades
from deepcoin.ground_truth.gt_allocation import resolve_sell_qty
from deepcoin.ground_truth.hybrid_dd_calibrate import load_hybrid_dd_params
from deepcoin.ops.paper_portfolio import PaperPortfolio
from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.paths import MATCHING_FIRE_OUTCOMES
@dataclass
@@ -29,6 +36,190 @@ class SimTradeResult:
leg_id: int | None = None
def bootstrap_monitor_signals_from_outcomes(
*,
end_dt: str | None = None,
lookback_days: int | None = None,
) -> list[dict[str, Any]]:
"""
04 fire_outcomes 에서 monitor 규칙 발화를 로드 (시뮬 all_monitor 와 동일 입력).
Args:
end_dt: 이 시각 이전만 포함 (None=전체).
lookback_days: CHART_LOOKBACK_DAYS 대신 사용할 일수.
Returns:
{dt, rule_id, side, close} 리스트 (시각순).
"""
import pandas as pd
path = MATCHING_FIRE_OUTCOMES
if not path.is_file():
return []
monitor_ids = {r["rule_id"] for r in load_monitor_rules()}
if not monitor_ids:
return []
df = pd.read_csv(path)
if df.empty or "rule_id" not in df.columns:
return []
sub = df[df["rule_id"].isin(monitor_ids)].copy()
if sub.empty:
return []
sub["dt"] = sub["dt"].astype(str)
if end_dt:
sub = sub[sub["dt"] <= str(end_dt)]
if lookback_days is not None and lookback_days > 0:
end_ts = pd.to_datetime(sub["dt"].max()) if end_dt is None else pd.to_datetime(end_dt)
start = end_ts - pd.Timedelta(days=int(lookback_days))
sub = sub[pd.to_datetime(sub["dt"]) >= start]
elif lookback_days is None and CHART_LOOKBACK_DAYS > 0:
end_ts = pd.to_datetime(sub["dt"].max())
start = end_ts - pd.Timedelta(days=int(CHART_LOOKBACK_DAYS))
sub = sub[pd.to_datetime(sub["dt"]) >= start]
rows: list[dict[str, Any]] = []
for _, r in sub.iterrows():
rows.append(
{
"dt": str(r["dt"]),
"rule_id": str(r["rule_id"]),
"side": str(r["side"]),
"close": float(r["close"]),
}
)
return sort_hits_sim_order(rows)
def merge_signal_histories(
*histories: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""
발화 이력 병합 (dt+rule_id+side 기준 중복 제거, 시뮬 정렬).
Args:
*histories: 신호 dict 리스트들.
Returns:
병합·정렬된 리스트.
"""
seen: set[tuple[str, str, str]] = set()
merged: list[dict[str, Any]] = []
for hist in histories:
for h in hist:
key = hit_key(h)
if key in seen:
continue
seen.add(key)
merged.append(
{
"dt": key[0],
"rule_id": key[1],
"side": key[2],
"close": float(h["close"]),
}
)
return sort_hits_sim_order(merged)
def build_live_signal_history(
persisted: list[dict[str, Any]] | None = None,
*,
bootstrap_fires: bool | None = None,
) -> list[dict[str, Any]]:
"""
운영 hybrid 이력: fire_outcomes 부트스트랩 + live 저장분 병합.
Args:
persisted: live_signal_history.json signals.
bootstrap_fires: fire_outcomes 부트스트랩 여부. None이면 config.
Returns:
sim_causal_hybrid 입력과 동일 형식의 이력.
"""
use_boot = (
LIVE_HYBRID_BOOTSTRAP_FIRES if bootstrap_fires is None else bootstrap_fires
)
parts: list[list[dict[str, Any]]] = []
if use_boot:
boot = bootstrap_monitor_signals_from_outcomes()
if boot:
parts.append(boot)
if persisted:
parts.append(persisted)
if not parts:
return []
return merge_signal_histories(*parts)
class HybridSimPortfolio:
"""
hybrid 배분 결과를 현금·보유 수량에 적용 (시뮬·live plan 공용, API 미사용).
"""
def __init__(self) -> None:
"""초기 현금만 보유."""
self.cash_krw: float = float(GT_INITIAL_CASH_KRW)
self.qty: float = 0.0
self.qty_by_leg: dict[int, float] = {}
self.sell_leg: int | None = None
self.sell_base_qty: float = 0.0
def apply_buy(self, amount_krw: float, price: float, leg_id: int) -> bool:
"""
매수 체결 (가용 현금·수수료 범위).
Args:
amount_krw: 매수 원화.
price: 체결가.
leg_id: leg ID.
Returns:
체결 성공 여부.
"""
if amount_krw <= 0 or price <= 0:
return False
fee = amount_krw * TRADING_FEE_RATE
if self.cash_krw < amount_krw + fee:
return False
self.cash_krw -= amount_krw + fee
bought = amount_krw / price
self.qty += bought
self.qty_by_leg[leg_id] = self.qty_by_leg.get(leg_id, 0.0) + bought
self.sell_leg = None
self.sell_base_qty = 0.0
return True
def apply_sell(self, amount_krw: float, sell_qty: float, price: float, leg_id: int) -> bool:
"""
매도 체결 (보유 수량 필요).
Args:
amount_krw: 매도 원화(총액).
sell_qty: 매도 수량.
price: 체결가.
leg_id: leg ID.
Returns:
체결 성공 여부.
"""
if sell_qty <= 0 or amount_krw <= 0:
return False
leg_qty = self.qty_by_leg.get(leg_id, 0.0)
if leg_qty <= 1e-12:
return False
fee = amount_krw * TRADING_FEE_RATE
self.cash_krw += amount_krw - fee
leg_qty -= sell_qty
self.qty_by_leg[leg_id] = max(leg_qty, 0.0)
self.qty = max(self.qty - sell_qty, 0.0)
if self.qty < 1e-12:
self.qty = 0.0
if self.qty_by_leg.get(leg_id, 0.0) <= 1e-12:
self.qty_by_leg.pop(leg_id, None)
self.sell_leg = None
self.sell_base_qty = 0.0
return True
def hit_key(hit: dict[str, Any]) -> tuple[str, str, str]:
"""발화 고유 키 (dt, rule_id, side)."""
return (str(hit["dt"]), str(hit["rule_id"]), str(hit["side"]))
@@ -55,14 +246,17 @@ def sort_hits_sim_order(hits: list[dict[str, Any]]) -> list[dict[str, Any]]:
def _signals_for_hybrid(
signal_history: list[dict[str, Any]],
*,
approved_buy_rules: set[str] | None,
approved_buy_rules: set[str] | None = None,
) -> list[dict[str, Any]]:
"""
hybrid 배분용 신호 목록 (EV/WF 미통과 매수 제외).
hybrid 배분용 신호 목록.
sim_causal_hybrid 와 동일하려면 approved_buy_rules=None (monitor 전체 발화).
운영에서 추가로 EV/WF 매수만 허용하려면 rule_id 집합을 넘깁니다.
Args:
signal_history: {dt, rule_id, side, close}.
approved_buy_rules: 허용 매수 rule_id.
approved_buy_rules: None=필터 없음. set 이면 해당 매수 rule_id.
Returns:
시뮬 입력 trade dict 리스트.
@@ -71,7 +265,11 @@ def _signals_for_hybrid(
for h in sort_hits_sim_order(signal_history):
side = str(h["side"])
rid = str(h["rule_id"])
if side == "buy" and approved_buy_rules is not None and rid not in approved_buy_rules:
if (
side == "buy"
and approved_buy_rules is not None
and rid not in approved_buy_rules
):
continue
out.append(
{
@@ -118,29 +316,19 @@ def size_monitor_signals(
return sized
def _find_sized_trade(sized: list[dict[str, Any]], hit: dict[str, Any]) -> dict[str, Any] | None:
"""sized 목록에서 발화 1건 조회."""
dt, rid, side = hit_key(hit)
for t in sized:
action = str(t.get("action", t.get("side", "")))
if str(t.get("dt")) == dt and str(t.get("rule_id", "")) == rid and action == side:
return t
return None
def replay_paper_portfolio(
def replay_hybrid_signals(
signal_history: list[dict[str, Any]],
ohlc_df: pd.DataFrame,
*,
approved_buy_rules: set[str] | None = None,
) -> tuple[PaperPortfolio, dict[tuple[str, str, str], SimTradeResult]]:
) -> tuple[HybridSimPortfolio, dict[tuple[str, str, str], SimTradeResult]]:
"""
신호 이력 전체를 시뮬 엔진으로 재생 → 모의 계좌(GT_INITIAL_CASH_KRW) 상태.
신호 이력 전체를 hybrid 배분·체결 규칙으로 재생 (simulate_portfolio_steps 동일).
Args:
signal_history: Phase C 누적 발화.
signal_history: 누적 발화.
ohlc_df: 3m OHLC.
approved_buy_rules: EV/WF 통과 매수 규칙.
approved_buy_rules: None=시뮬 동일. set 이면 매수 rule_id 필터.
Returns:
(portfolio, hit_key → SimTradeResult).
@@ -148,67 +336,97 @@ def replay_paper_portfolio(
sized = size_monitor_signals(
signal_history, ohlc_df, approved_buy_rules=approved_buy_rules
)
paper = PaperPortfolio()
paper.cash_krw = float(GT_INITIAL_CASH_KRW)
paper.qty = 0.0
paper.qty_by_leg = {}
portfolio = HybridSimPortfolio()
results: dict[tuple[str, str, str], SimTradeResult] = {}
fee_rate = TRADING_FEE_RATE
current_leg: int | None = None
leg_budget = 0.0
leg_sell_idxs: dict[int, list[int]] = {}
for i, t in enumerate(sized):
lid = int(t.get("leg_id", 0))
if str(t.get("action", t.get("side"))) == "sell":
leg_sell_idxs.setdefault(lid, []).append(i)
sell_leg: int | None = None
sell_base_qty = 0.0
for i, t in enumerate(sized):
side = str(t.get("action", t.get("side", "")))
for t in sorted(sized, key=lambda x: x["dt"]):
action = str(t.get("action", t.get("side", "")))
price = float(t["price"])
if price <= 0:
continue
dt = str(t["dt"])
rid = str(t.get("rule_id", ""))
leg_id = int(t.get("leg_id", 0))
hit = {"dt": dt, "rule_id": rid, "side": side, "close": price}
weight = float(t.get("weight", 1.0))
hit = {"dt": dt, "rule_id": rid, "side": action, "close": price}
key = hit_key(hit)
amount = float(t.get("amount_krw") or 0)
if side == "buy":
if action == "buy":
ak = t.get("amount_krw")
if ak is not None and float(ak) > 0:
amount = min(
float(ak),
max(portfolio.cash_krw / (1.0 + fee_rate), 0.0),
)
else:
if leg_id != current_leg:
current_leg = leg_id
leg_budget = portfolio.cash_krw
amount = min(
leg_budget * weight,
max(portfolio.cash_krw / (1.0 + fee_rate), 0.0),
)
if amount <= 0:
results[key] = SimTradeResult(
hit, 0.0, 0.0, False, "시뮬 매수 스킵(현금·tier)"
)
continue
ok = paper.apply_buy(amount, price, leg_id)
msg = f"paper_buy sim leg={leg_id}{amount:,.0f}" if ok else "paper_buy 실패"
results[key] = SimTradeResult(
hit, amount, 0.0, ok, msg, leg_id=leg_id
fee = amount * fee_rate
portfolio.cash_krw -= amount + fee
bought = amount / price
portfolio.qty += bought
portfolio.qty_by_leg[leg_id] = (
portfolio.qty_by_leg.get(leg_id, 0.0) + bought
)
portfolio.sell_leg = None
portfolio.sell_base_qty = 0.0
results[key] = SimTradeResult(
hit,
amount,
0.0,
True,
f"sim_buy leg={leg_id}{amount:,.0f}",
leg_id=leg_id,
)
sell_leg = None
continue
leg_qty = paper.qty_by_leg.get(leg_id, 0.0)
if leg_qty <= 1e-12:
results[key] = SimTradeResult(hit, 0.0, 0.0, False, "모의 보유 없음")
continue
if amount <= 0:
results[key] = SimTradeResult(hit, 0.0, 0.0, False, "시뮬 매도 스킵")
if action == "sell" and portfolio.qty > 0:
leg_qty = portfolio.qty_by_leg.get(leg_id, portfolio.qty)
if portfolio.sell_leg != leg_id:
portfolio.sell_leg = leg_id
portfolio.sell_base_qty = leg_qty
sell_qty = resolve_sell_qty(
t, leg_qty, price, portfolio.sell_base_qty, weight
)
if sell_qty <= 0:
results[key] = SimTradeResult(
hit, 0.0, 0.0, False, "시뮬 매도 스킵"
)
continue
gross = sell_qty * price
fee = gross * fee_rate
portfolio.cash_krw += gross - fee
leg_qty -= sell_qty
portfolio.qty_by_leg[leg_id] = max(leg_qty, 0.0)
portfolio.qty = max(portfolio.qty - sell_qty, 0.0)
if portfolio.qty < 1e-12:
portfolio.qty = 0.0
results[key] = SimTradeResult(
hit,
gross,
sell_qty,
True,
f"sim_sell qty={sell_qty:.4f}{gross:,.0f}",
leg_id=leg_id,
)
continue
if sell_leg != leg_id:
sell_leg = leg_id
sell_base_qty = leg_qty
rem = [j for j in leg_sell_idxs.get(leg_id, []) if j >= i]
is_last = bool(rem) and i == rem[-1]
sell_qty = leg_qty if is_last else amount / price if price > 0 else 0.0
results[key] = SimTradeResult(hit, 0.0, 0.0, False, "보유 없음")
ok = paper.apply_sell(amount, sell_qty, price, leg_id)
msg = f"paper_sell sim qty={sell_qty:.4f}{amount:,.0f}" if ok else "paper_sell 실패"
results[key] = SimTradeResult(
hit, amount, sell_qty, ok, msg, leg_id=leg_id
)
return paper, results
return portfolio, results
def plan_live_hit(
@@ -228,7 +446,7 @@ def plan_live_hit(
approved_buy_rules: 매수 허용.
Returns:
SimTradeResult (dry-run replay_paper_portfolio 와 동일).
SimTradeResult.
"""
if ohlc_df is None or getattr(ohlc_df, "empty", True):
return SimTradeResult(hit, 0.0, 0.0, False, "OHLC 없음")
@@ -246,7 +464,7 @@ def plan_live_hit(
"close": float(hit["close"]),
}
)
_, results = replay_paper_portfolio(
_, results = replay_hybrid_signals(
hist, ohlc_df, approved_buy_rules=approved_buy_rules
)
res = results.get((dt, rid, side))