인과적 GT 신호·복리 배분 시뮬을 도입하고 운영 정합성을 맞춘다.

미래 데이터를 쓰지 않는 causal 신호/tier와 전기간 복리 포트폴리오 비교로 GT 대비 sim_sized 검증 경로를 정리하고, 일한도·매수 상한·live_buy 스케일을 제거한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
xavis
2026-05-31 19:50:54 +09:00
parent 5842cc9fa3
commit e68bb44083
16 changed files with 1817 additions and 474 deletions

View File

@@ -1,5 +1,5 @@
"""
규칙 발화 기반 고정 금액 체결 포트폴리오 시뮬 (GT HTML 카드·테이블용).
규칙 발화 기반 GT 모델 복리 포트폴리오 시뮬.
"""
from __future__ import annotations
@@ -10,14 +10,13 @@ import pandas as pd
from config import (
GT_INITIAL_CASH_KRW,
LIVE_DAILY_KRW_MAX,
LIVE_MAX_TRADES_PER_DAY,
GT_SIGNAL_CAUSAL,
LIVE_ORDER_KRW,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.gt_allocation import simulate_portfolio_summary
from deepcoin.matching.position_sizing import (
attach_dynamic_buy_amounts,
load_sizing_context_from_gt,
attach_gt_model_amounts,
)
@@ -43,99 +42,95 @@ def _planned_order_krw(
return float(order_krw)
def sort_fires_chronological(fires: pd.DataFrame) -> pd.DataFrame:
"""
발화를 시간순 정렬 (일·금액 한도 없음).
Args:
fires: fire_outcomes.
Returns:
정렬된 DataFrame.
"""
if fires.empty:
return fires
return fires.sort_values("dt").copy()
def simulate_fires_compound(
fires: pd.DataFrame,
*,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""
발화 → GT tier 복리 amount_krw 배분 (allocate_order_amounts_chronological).
Args:
fires: fire_outcomes.
initial_cash: 시작 현금 (이후 체결마다 누적).
fee_rate: 수수료율.
Returns:
(amount_krw 채워진 trade dict, stats).
"""
trades = fires_to_trade_list(
fires,
apply_dynamic_sizing=True,
initial_cash=initial_cash,
fee_rate=fee_rate,
)
n_in = int(len(fires))
n_out = sum(1 for t in trades if float(t.get("amount_krw") or 0) > 0)
return trades, {
"input_fires": n_in,
"executed": n_out,
"skipped": max(n_in - n_out, 0),
}
def select_capped_fires(
fires: pd.DataFrame,
*,
use_dynamic_sizing: bool = True,
) -> pd.DataFrame:
"""
일한도·회수 제한으로 체결 가능한 발화만 남깁니다.
시각순 발화 반환 (레거시명; 일·금액 한도 미적용).
Args:
fires: fire_outcomes (dt, side, close, rule_id …).
fires: fire_outcomes.
use_dynamic_sizing: 미사용 (하위 호환).
Returns:
체결된 발화 DataFrame.
정렬된 발화 DataFrame.
"""
if fires.empty:
return fires
gt_trades, large_legs, approved = load_sizing_context_from_gt()
df = fires.sort_values("dt").copy()
df["ts"] = pd.to_datetime(df["dt"])
df["day"] = df["ts"].dt.date.astype(str)
cash = float(GT_INITIAL_CASH_KRW)
qty = 0.0
taken: list[pd.DataFrame] = []
for _, day_grp in df.groupby("day", sort=True):
spent = 0.0
n_trades = 0
idxs: list[Any] = []
for idx, row in day_grp.iterrows():
if n_trades >= LIVE_MAX_TRADES_PER_DAY:
break
side = row["side"]
price = float(row["close"])
if side == "buy" and use_dynamic_sizing:
from deepcoin.matching.position_sizing import (
compute_buy_amount_krw,
live_buy_asset_pct_scale,
)
scale = live_buy_asset_pct_scale(
str(row["rule_id"]),
str(row["dt"]),
gt_trades,
approved_rules=approved,
large_legs=large_legs,
)
planned = compute_buy_amount_krw(
cash,
qty,
price,
1.0,
1.0,
asset_pct_scale=scale,
)
else:
planned = float(LIVE_ORDER_KRW)
if side == "buy":
if spent + planned > LIVE_DAILY_KRW_MAX:
break
if planned <= 0:
continue
fee = planned * TRADING_FEE_RATE
cash -= planned + fee
qty += planned / price if price > 0 else 0.0
spent += planned
elif side == "sell" and qty > 0:
gross = qty * price
cash += gross * (1.0 - TRADING_FEE_RATE)
qty = 0.0
n_trades += 1
idxs.append(idx)
if idxs:
taken.append(day_grp.loc[idxs])
if not taken:
return df.iloc[0:0]
return pd.concat(taken, ignore_index=True)
_ = use_dynamic_sizing
return sort_fires_chronological(fires)
def fires_to_trade_list(
fires: pd.DataFrame,
*,
apply_dynamic_sizing: bool = True,
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
) -> list[dict[str, Any]]:
"""
발화 DataFrame을 포트폴리오 시뮬용 trade dict 리스트로 변환.
발화 → GT 모델 amount_krw가 채워진 trade dict (복리 배분).
Args:
fires: 체결 대상 발화.
apply_dynamic_sizing: True면 GT tier 복리 배분.
initial_cash: 시작 현금 (누적 복리).
fee_rate: 수수료율.
Returns:
dt, action, price 키를 가진 dict 리스트.
dt, action, price, amount_krw 키 dict 리스트.
"""
if fires.empty:
return []
rows: list[dict[str, Any]] = []
for _, r in fires.sort_values("dt").iterrows():
for _, r in sort_fires_chronological(fires).iterrows():
rows.append(
{
"dt": str(r["dt"]),
@@ -145,13 +140,11 @@ def fires_to_trade_list(
"forward_ret_pct": float(r.get("forward_ret_pct", 0)),
}
)
if apply_dynamic_sizing and rows:
gt_trades, large_legs, approved = load_sizing_context_from_gt()
attach_dynamic_buy_amounts(
if apply_dynamic_sizing:
attach_gt_model_amounts(
rows,
gt_trades=gt_trades,
approved_rules=approved,
large_legs=large_legs,
initial_cash=initial_cash,
fee_rate=fee_rate,
)
return rows
@@ -164,7 +157,7 @@ def simulate_sized_portfolio(
fallback_order_krw: float = LIVE_ORDER_KRW,
) -> dict[str, Any]:
"""
trade.amount_krw(총자산 비율 배분) 기준 포트폴리오 시뮬.
trade.amount_krw(GT 모델·복리 배분) 기준 포트폴리오 시뮬 + MDD.
Args:
trades: 시간순 trade dict (amount_krw 권장).
@@ -174,16 +167,25 @@ def simulate_sized_portfolio(
fallback_order_krw: amount_krw 없을 때 1회 금액.
Returns:
simulate_truth_portfolio와 동일 키 구조.
simulate_truth_portfolio와 동일 키 + max_drawdown_pct.
"""
return simulate_fixed_order_portfolio(
if trades and not any(float(t.get("amount_krw") or 0) > 0 for t in trades):
attach_gt_model_amounts(trades, initial_cash=initial_cash, fee_rate=fee_rate)
result = simulate_portfolio_summary(
trades,
order_krw=fallback_order_krw,
initial_cash=initial_cash,
fee_rate=fee_rate,
last_price=last_price,
sizing_mode="amount_krw",
use_amount_krw=True,
)
result["sizing_mode"] = (
"gt_model_compound_causal" if GT_SIGNAL_CAUSAL else "gt_model_compound"
)
result["sizing_note"] = (
"전기간 복리·GT tier·총자산×비중, 보유현금 한도; "
+ ("인과적 신호·tier(미래 미사용)" if GT_SIGNAL_CAUSAL else "상한 없음")
)
return result
def simulate_fixed_order_portfolio(
@@ -203,7 +205,7 @@ def simulate_fixed_order_portfolio(
initial_cash: 시작 현금.
fee_rate: 수수료율.
last_price: 미청산 평가 종가.
sizing_mode: 'fixed' | 'amount_krw' (없으면 order_krw).
sizing_mode: 'fixed' | 'amount_krw'.
Returns:
simulate_truth_portfolio와 동일 키 구조.

View File

@@ -1,5 +1,5 @@
"""
총자산 대비 최적 매수율(비중) · 현금 한도 · leg 상위·EV/WF 통과 대형 매수.
총자산 대비 GT 모델 매수율(비중) · 보유 현금 한도 · leg tier 배분.
"""
from __future__ import annotations
@@ -15,19 +15,14 @@ from config import (
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
_GT_ALLOC_ANALYSIS_CACHE: dict[str, Any] | None = None
def portfolio_totals(
cash: float,
@@ -78,10 +73,12 @@ def compute_buy_amount_krw(
fee_rate: float = TRADING_FEE_RATE,
) -> float:
"""
총자산 × (최적 매수율 × scale)목표, 가용 현금을 넘지 않게 매수 원화를 산출합니다.
목표=총보유자산×(최적 매수율×scale), 체결=min(목표, 보유현금/(1+fee)) 로 매수 원화를 산출합니다.
보유 현금 = 총보유자산 코인평가액(cash 인자).
Args:
cash: 현금.
cash: 보유 현금(가용 원화).
qty: 보유 수량.
price: 체결가.
weight: 타점 비중.
@@ -95,8 +92,8 @@ def compute_buy_amount_krw(
"""
if price <= 0:
return 0.0
total_asset, _, _ = portfolio_totals(cash, qty, price)
budget = max(cash / (1.0 + fee_rate), 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)
@@ -105,6 +102,27 @@ def compute_buy_amount_krw(
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,
@@ -215,6 +233,8 @@ def load_ev_wf_approved_rule_ids(
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,
@@ -258,6 +278,72 @@ def load_ev_wf_approved_rule_ids(
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,
@@ -267,7 +353,7 @@ def live_buy_asset_pct_scale(
large_legs: set[int],
) -> float:
"""
실거래·시뮬 매수: EV/WF 통과 규칙 + leg 상위만 대형 비율.
실거래 전용 매수 tier (EV/WF·leg 상위). 시뮬은 gt_tier_scale_for_trade 사용.
Args:
rule_id: 규칙 ID.
@@ -279,6 +365,8 @@ def live_buy_asset_pct_scale(
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)
@@ -287,10 +375,193 @@ def live_buy_asset_pct_scale(
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]],
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,
@@ -298,60 +569,18 @@ def attach_dynamic_buy_amounts(
fee_rate: float = TRADING_FEE_RATE,
) -> list[dict[str, Any]]:
"""
시뮬 발화 trade dict에 amount_krw(총자산 비율·현금 한도)를 채웁니다.
시뮬 발화 trade dict에 amount_krw(GT 모델·보유 현금 한도)를 채웁니다.
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가 채워진 동일 리스트.
attach_gt_model_amounts 별칭.
"""
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
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(
@@ -374,5 +603,4 @@ def load_sizing_context_from_gt(
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
return trades, large, set()

View File

@@ -380,6 +380,15 @@ def build_rule_candidates(
seen_ids.add(cr["rule_id"])
print(f"[04-1] 캘리브레이션 규칙 병합 → 총 {len(rules)}")
from deepcoin.ground_truth.gt_signal_rules import build_gt_model_rules
seen_ids = {r["rule_id"] for r in rules}
for gr in build_gt_model_rules():
if gr["rule_id"] not in seen_ids:
rules.append(gr)
seen_ids.add(gr["rule_id"])
print(f"[04-1] GT 모델 일반화 규칙 추가 → 총 {len(rules)}")
out = {
"source": str(path),
"profile_json": str(ANALYSIS_GT_MTF_PROFILE_JSON),

View File

@@ -188,6 +188,22 @@ def build_mtf_scan_frame(
if align_needed:
out = _add_align_columns_vectorized(out)
gt_needed = [c for c in needed_cols if c.startswith("gt_")]
bb_in_rules = "bb_pos" in needed_cols
if gt_needed or bb_in_rules:
ef = enriched[primary]
for src in ("Low", "High", "low", "high", "bb_pos", "Open", "Volume"):
if src in ef.columns and src not in out.columns:
out[src] = ef[src].reindex(out.index)
if "Low" not in out.columns and "low" in out.columns:
out["Low"] = out["low"]
if "High" not in out.columns and "high" in out.columns:
out["High"] = out["high"]
from deepcoin.ground_truth.gt_signal_rules import enrich_scan_frame_gt_signals
# 시뮬·live 스캔: 타점 판단은 항상 인과적 (GT 정답 생성은 ground_truth.py 별도)
out = enrich_scan_frame_gt_signals(out, causal=True)
out = out.loc[:, ~out.columns.duplicated()]
out = out.dropna(subset=["close"])
print(f"[04b] 스캔 프레임: {len(out):,}× {len(out.columns)}")

View File

@@ -13,8 +13,6 @@ import pandas as pd
from config import (
GT_INITIAL_CASH_KRW,
LIVE_DAILY_KRW_MAX,
LIVE_MAX_TRADES_PER_DAY,
LIVE_ORDER_KRW,
LIVE_SLIPPAGE_PCT,
MATCH_HOLDOUT_RATIO,
@@ -32,13 +30,19 @@ from config import (
from deepcoin.ground_truth.ground_truth import (
load_ground_truth,
order_trades_chronological,
simulate_truth_portfolio,
)
from deepcoin.ground_truth.gt_allocation import simulate_portfolio_summary
from deepcoin.ground_truth.gt_model import (
default_model,
model_to_dict,
summarize_leg_weights,
weight_policy_summary,
)
from deepcoin.matching.portfolio_sim import (
fires_to_trade_list,
select_capped_fires,
simulate_fixed_order_portfolio,
simulate_sized_portfolio,
sort_fires_chronological,
)
from deepcoin.matching.select_rules import _rule_metrics, _split_train_valid_holdout
from deepcoin.paths import resolve_ground_truth_file
@@ -129,7 +133,7 @@ def simulate_live_order_cap(
holdout_only: bool = True,
) -> dict[str, Any]:
"""
1회·일 한도·슬리피지 가정으로 체결 가능한 발화 집계.
GT 복리 배분·슬리피지 가정으로 체결 가능한 발화 집계 (일·금액 한도 없음).
Args:
outcomes: fire_outcomes (split 컬럼 있으면 holdout 필터 가능).
@@ -142,71 +146,23 @@ def simulate_live_order_cap(
if outcomes.empty:
return {"rules": {}, "note": "발화 없음"}
df = outcomes
df = outcomes.copy()
if holdout_only and "split" in df.columns:
df = df[df["split"] == "holdout"]
if rule_ids is not None:
df = df[df["rule_id"].isin(rule_ids)]
df = df.sort_values("dt").copy()
df["ts"] = pd.to_datetime(df["dt"])
df["day"] = df["ts"].dt.date.astype(str)
slip = LIVE_SLIPPAGE_PCT
taken_rows: list[pd.DataFrame] = []
from deepcoin.matching.position_sizing import (
compute_buy_amount_krw,
live_buy_asset_pct_scale,
load_sizing_context_from_gt,
)
trades = fires_to_trade_list(sort_fires_chronological(df), apply_dynamic_sizing=True)
executed_dts = {
t["dt"]
for t in trades
if t.get("action") == "sell" or float(t.get("amount_krw") or 0) > 0
}
if not executed_dts:
return {"rules": {}, "taken_count": 0, "total_count": int(len(df))}
gt_trades, large_legs, approved = load_sizing_context_from_gt()
cash = float(GT_INITIAL_CASH_KRW)
qty = 0.0
for day, day_grp in df.groupby("day", sort=True):
spent = 0.0
n_trades = 0
taken_idx: list[int] = []
for idx, row in day_grp.iterrows():
if n_trades >= LIVE_MAX_TRADES_PER_DAY:
break
side = row["side"]
price = float(row["close"])
if side == "buy":
scale = live_buy_asset_pct_scale(
str(row["rule_id"]),
str(row["dt"]),
gt_trades,
approved_rules=approved,
large_legs=large_legs,
)
planned = compute_buy_amount_krw(
cash, qty, price, 1.0, 1.0, asset_pct_scale=scale
)
else:
planned = float(LIVE_ORDER_KRW)
if side == "buy":
if planned <= 0:
continue
if spent + planned > LIVE_DAILY_KRW_MAX:
break
fee = planned * TRADING_FEE_RATE
cash -= planned + fee
qty += planned / price if price > 0 else 0.0
spent += planned
elif side == "sell" and qty > 0:
gross = qty * price
cash += gross * (1.0 - TRADING_FEE_RATE)
qty = 0.0
n_trades += 1
taken_idx.append(idx)
if taken_idx:
taken_rows.append(day_grp.loc[taken_idx])
if not taken_rows:
return {"rules": {}, "taken_count": 0}
taken = pd.concat(taken_rows, ignore_index=True)
taken = df[df["dt"].astype(str).isin(executed_dts)].copy()
taken["adj_ret_pct"] = taken["forward_ret_pct"] - slip
by_rule: dict[str, Any] = {}
@@ -221,10 +177,8 @@ def simulate_live_order_cap(
return {
"assumptions": {
"order_krw": LIVE_ORDER_KRW,
"daily_krw_max": LIVE_DAILY_KRW_MAX,
"slippage_pct": slip,
"sizing": "total_asset_pct_ev_wf_large_leg",
"sizing": "gt_model_compound_no_daily_cap",
},
"taken_count": int(len(taken)),
"total_count": int(len(df)),
@@ -338,45 +292,126 @@ def build_simulation_report(
gt_data = load_ground_truth(resolve_ground_truth_file()) or {}
gt_trades = gt_data.get("trades") or []
mark = (gt_data.get("summary") or {}).get("mark_price")
if gt_trades:
portfolio_compare["ground_truth_chrono"] = simulate_truth_portfolio(
order_trades_chronological(gt_trades),
gt_chrono = order_trades_chronological(gt_trades) if gt_trades else []
from deepcoin.ground_truth.gt_signal_rules import gt_signal_rule_ids
from config import GT_SIGNAL_CAUSAL, SIM_CAUSAL_TIER
from deepcoin.matching.position_sizing import load_gt_allocation_analysis
gt_alloc_analysis = load_gt_allocation_analysis(gt_trades) if gt_trades else {}
if gt_chrono:
if not any(float(t.get("amount_krw") or 0) > 0 for t in gt_chrono):
from deepcoin.ground_truth.ground_truth import allocate_gt_order_amounts
allocate_gt_order_amounts(gt_chrono)
portfolio_compare["ground_truth_chrono"] = simulate_portfolio_summary(
gt_chrono,
last_price=float(mark) if mark else None,
use_amount_krw=True,
)
holdout = outcomes[
outcomes["rule_id"].isin(monitor_ids) & (outcomes["split"] == "holdout")
]
capped = select_capped_fires(holdout)
if not capped.empty:
# 전기간 monitor 규칙 — 100만원에서 복리 (holdout만 X)
all_monitor = outcomes[outcomes["rule_id"].isin(monitor_ids)]
if not all_monitor.empty:
sim_trades_full = fires_to_trade_list(sort_fires_chronological(all_monitor))
portfolio_compare["sim_sized"] = simulate_sized_portfolio(
fires_to_trade_list(capped, apply_dynamic_sizing=True),
sim_trades_full,
last_price=float(mark) if mark else None,
)
portfolio_compare["sim_fixed_order"] = simulate_fixed_order_portfolio(
fires_to_trade_list(capped, apply_dynamic_sizing=False),
fires_to_trade_list(all_monitor, apply_dynamic_sizing=False),
last_price=float(mark) if mark else None,
)
# GT 모델 일반화 규칙 (ZigZag+BB 매수 / ZigZag 고점 매도)
gt_buy_rule = "gt_model_buy_zigzag_bb"
gt_sell_rule = "gt_model_sell_zigzag_peak"
gt_pair_ids = {gt_buy_rule, gt_sell_rule}
if gt_pair_ids.issubset(set(outcomes["rule_id"].unique())):
gt_pair_fires = outcomes[outcomes["rule_id"].isin(gt_pair_ids)]
gt_pair_trades = fires_to_trade_list(sort_fires_chronological(gt_pair_fires))
portfolio_compare["sim_gt_model"] = simulate_sized_portfolio(
gt_pair_trades,
last_price=float(mark) if mark else None,
)
holdout = outcomes[
outcomes["rule_id"].isin(monitor_ids) & (outcomes["split"] == "holdout")
]
if not holdout.empty and not all_monitor.empty:
full_trades = fires_to_trade_list(sort_fires_chronological(all_monitor))
if full_trades:
from deepcoin.ground_truth.gt_allocation import simulate_portfolio_steps
steps = simulate_portfolio_steps(full_trades, use_amount_krw=True)
if steps:
outcomes_ts = outcomes.copy()
outcomes_ts["ts"] = pd.to_datetime(outcomes_ts["dt"])
h0 = outcomes_ts["ts"].quantile(1.0 - MATCH_HOLDOUT_RATIO)
assets = [(s["dt"], float(s["total_asset_krw"])) for s in steps]
pre = [a for d, a in assets if pd.to_datetime(d) < h0]
in_h = [a for d, a in assets if pd.to_datetime(d) >= h0]
asset_start = pre[-1] if pre else float(GT_INITIAL_CASH_KRW)
asset_end = in_h[-1] if in_h else assets[-1][1]
ho_pnl_pct = (
(asset_end - asset_start) / asset_start * 100.0
if asset_start > 0
else 0.0
)
portfolio_compare["sim_sized_holdout"] = {
"initial_asset_krw": round(asset_start, 0),
"final_asset_krw": round(asset_end, 0),
"pnl_krw": round(asset_end - asset_start, 0),
"pnl_pct": round(ho_pnl_pct, 2),
"note": "전기간 복리 후 holdout 구간 자산 증감 (1M 재시작 아님)",
"trade_count": int(len(holdout)),
}
if portfolio_compare.get("sim_sized") and portfolio_compare.get("ground_truth_chrono"):
gt_pnl = float(portfolio_compare["ground_truth_chrono"].get("pnl_pct", 0))
sim_pnl = float(portfolio_compare["sim_sized"].get("pnl_pct", 0))
portfolio_compare["gt_capture_ratio"] = round(
sim_pnl / gt_pnl if abs(gt_pnl) > 1e-6 else 0.0,
4,
)
portfolio_compare["gt_pnl_pct"] = gt_pnl
portfolio_compare["sim_sized_pnl_pct"] = sim_pnl
if portfolio_compare.get("sim_gt_model"):
gtp = float(portfolio_compare["sim_gt_model"].get("pnl_pct", 0))
portfolio_compare["gt_model_capture_ratio"] = round(
gtp / gt_pnl if abs(gt_pnl) > 1e-6 else 0.0,
4,
)
portfolio_compare["gt_allocation_analysis"] = gt_alloc_analysis
portfolio_compare["causal_mode"] = {
"gt_signal_causal": GT_SIGNAL_CAUSAL,
"sim_causal_tier": SIM_CAUSAL_TIER,
"note": "인과적: t 시점까지 데이터만 사용 (운영 정합)",
}
gt_portfolio: dict[str, Any] = {}
if ANALYSIS_GT_CALIBRATION_JSON.is_file():
cal = json.loads(ANALYSIS_GT_CALIBRATION_JSON.read_text(encoding="utf-8"))
gt_portfolio = cal.get("final", {})
else:
from deepcoin.ground_truth.ground_truth import load_ground_truth
from deepcoin.matching.gt_asset_calibration import (
portfolio_asset_ratio,
)
gt_data = load_ground_truth(resolve_ground_truth_file()) or {}
trades = gt_data.get("trades") or []
mark = (gt_data.get("summary") or {}).get("mark_price")
gt_data_cal = load_ground_truth(resolve_ground_truth_file()) or {}
trades = gt_data_cal.get("trades") or []
mark_cal = (gt_data_cal.get("summary") or {}).get("mark_price")
if trades:
gt_portfolio = {
"portfolio": portfolio_asset_ratio(trades, set(), mark),
"portfolio": portfolio_asset_ratio(trades, set(), mark_cal),
"note": "캘리브레이션 미실행 — scripts/04_calibrate_gt_assets.py",
}
summaries = matched.get("all_rule_summaries") or matched.get("monitor_rules") or []
leg_weight_check = summarize_leg_weights(gt_trades) if gt_trades else {}
invalid_legs = [lid for lid, info in leg_weight_check.items() if not info.get("valid", True)]
return {
"label_mode": matched.get("label_mode"),
"train_ratio": MATCH_TRAIN_RATIO,
@@ -389,7 +424,13 @@ def build_simulation_report(
"live_order_cap_sim": live_cap,
"go_no_go": go,
"portfolio_compare": portfolio_compare,
"gt_model": gt_data.get("model"),
"gt_model": gt_data.get("model") or model_to_dict(default_model()),
"gt_weight_policy": weight_policy_summary(default_model()),
"gt_leg_weight_validation": {
"legs": leg_weight_check,
"invalid_leg_ids": invalid_legs,
"all_valid": len(invalid_legs) == 0,
},
"monitor_rules": matched.get("monitor_rules", []),
"gt_portfolio_calibration": gt_portfolio,
"criteria": {
@@ -450,6 +491,24 @@ def run_simulation_report(
)
cal = report.get("gt_portfolio_calibration") or {}
port = cal.get("portfolio") or {}
pc = report.get("portfolio_compare") or {}
if pc.get("gt_capture_ratio") is not None:
print(
f"[시뮬] GT 대비 sim_sized(전기간 복리): {pc.get('sim_sized_pnl_pct')}% "
f"/ GT {pc.get('gt_pnl_pct')}% "
f"(capture={pc.get('gt_capture_ratio'):.2%})"
)
if pc.get("gt_model_capture_ratio") is not None:
print(
f"[시뮬] GT 대비 sim_gt_model: "
f"{pc.get('sim_gt_model', {}).get('pnl_pct')}% "
f"(capture={pc.get('gt_model_capture_ratio'):.2%})"
)
if pc.get("sim_sized", {}).get("max_drawdown_pct") is not None:
print(
f"[시뮬] sim_sized MDD: {pc['sim_sized']['max_drawdown_pct']}% "
f"(GT MDD: {pc.get('ground_truth_chrono', {}).get('max_drawdown_pct')}%)"
)
if port.get("asset_ratio") is not None:
met = cal.get("targets_met", port.get("target_met_90"))
print(

View File

@@ -26,10 +26,10 @@ from deepcoin.ground_truth.ground_truth import (
)
from deepcoin.matching.portfolio_sim import (
fires_to_trade_list,
select_capped_fires,
simulate_fixed_order_portfolio,
simulate_fixed_order_portfolio_steps,
simulate_sized_portfolio,
sort_fires_chronological,
)
from deepcoin.matching.select_rules import _split_train_valid_holdout
from deepcoin.ops.chart_report import (
@@ -231,7 +231,7 @@ def _summary_cards_html(
gt_pnl, "정답 GT", len(gt_trades)
)
sim_sized_title = (
"시뮬·총자산% (EV/WF·leg상위) — "
"시뮬·GT tier 복리 (전기간, 상한 없음) — "
f'<span class="{go_cls}">{"GO" if go_flag else "NO-GO"}</span>'
)
sim_fixed_title = f"시뮬·고정 ₩{LIVE_ORDER_KRW:,}/회 (비교)"
@@ -280,15 +280,17 @@ def build_simulation_page_html(
monitor_ids = {r["rule_id"] for r in monitor_rules}
rules_by_id = {r["rule_id"]: r for r in monitor_rules}
sim_fires = pd.DataFrame()
holdout_fires = pd.DataFrame()
if op.is_file():
outcomes = pd.read_csv(op)
outcomes["split"] = _split_train_valid_holdout(outcomes)
sim_fires = outcomes[outcomes["rule_id"].isin(monitor_ids)].copy()
holdout_fires = outcomes[
(outcomes["rule_id"].isin(monitor_ids)) & (outcomes["split"] == "holdout")
].copy()
capped = select_capped_fires(holdout_fires)
compound_fires = sort_fires_chronological(sim_fires)
gt_data = load_ground_truth(gt_path or resolve_ground_truth_file()) or {}
gt_trades = gt_data.get("trades") or []
@@ -316,8 +318,8 @@ def build_simulation_page_html(
elif gt_summary.get("mark_price"):
close_val = float(gt_summary["mark_price"])
sim_trades_sized = fires_to_trade_list(capped, apply_dynamic_sizing=True)
sim_trades_fixed = fires_to_trade_list(capped, apply_dynamic_sizing=False)
sim_trades_sized = fires_to_trade_list(compound_fires, apply_dynamic_sizing=True)
sim_trades_fixed = fires_to_trade_list(compound_fires, apply_dynamic_sizing=False)
gt_pnl: dict[str, Any] = {}
if gt_trades:
@@ -377,14 +379,14 @@ def build_simulation_page_html(
return ""
sim_table = f"""
<h2>시뮬 타점 (holdout {len(holdout_fires)}건 → 체결 가정 {len(capped)}건)</h2>
<p class="meta">총자산×최적비중·현금한도·EV/WF통과·leg상위 대형 매수. 일한도·최대 거래수 적용.
<h2>시뮬 타점 (전기간 {len(sim_fires)}건 → 복리 체결 {len(compound_fires)}건)</h2>
<p class="meta">총자산×GT비중×leg tier·보유현금 한도·전기간 복리(일한도 없음).
가격 열 (+/-) = <b>{label_mode}</b> 구간 수익%.{_mark_note(close_val)}</p>
<div class="table-scroll">
<table>
<thead><tr><th>시각</th><th>구분</th><th>규칙</th><th>유형</th><th>가격</th>
<th>총 평가금액</th><th>승/패</th><th>비고</th></tr></thead>
<tbody>{_sim_fire_table_rows(capped, rules_by_id, sim_steps)}</tbody>
<tbody>{_sim_fire_table_rows(compound_fires, rules_by_id, sim_steps)}</tbody>
</table>
</div>"""
@@ -409,12 +411,12 @@ def build_simulation_page_html(
note = (
f"1단계 시뮬 · holdout {report.get('holdout_ratio', 0.15)} · "
f"발화 {len(holdout_fires)}건 / 체결가정 {len(capped)}건. "
f"전기간 발화 {len(sim_fires)}건 / holdout {len(holdout_fires)}건. "
"상단 카드: 초기 금액·총보유자산·초기 대비 증감율·수수료."
)
legend = (
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 = GT 체결 금액.<br>"
"● <b>시뮬</b> — 원 = holdout 발화 (차트). 테이블 = 일한도 적용 체결 순서."
"● <b>시뮬</b> — holdout 발화 (차트). 테이블 = 전기간 GT tier 복리 체결."
)
if frames is not None:
meta_line = (
@@ -434,7 +436,7 @@ def build_simulation_page_html(
gt_pnl,
sim_sized_pnl,
sim_fixed_pnl,
len(capped),
len(compound_fires),
go_flag,
model_note=model_note,
)
@@ -446,7 +448,7 @@ def build_simulation_page_html(
note=note,
truth_trades=gt_trades,
sim_trades=_fires_to_chart_trades(holdout_fires),
# 차트 마커는 holdout 전체; 카드·테이블은 일한도 capped
# 차트 마커는 holdout; 카드·테이블은 전기간 GT tier 복리
title_suffix="1단계 시뮬레이션 (monitor · holdout)",
legend_html=legend,
footer_sections=sections,