GT 총자산 비율 매수·leg 티어 배분과 시뮬/실거래 포지션 사이징을 통합한다.

타점·비중을 gt_model로 일반화하고, amount_krw 시각순 배분·EV/WF·상위 leg 대형 매수를
position_sizing과 시뮬 HTML(고정 ₩/회 비교)에 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 16:11:49 +09:00
parent 2cb67c42b3
commit 5842cc9fa3
14 changed files with 2073 additions and 182 deletions

View File

@@ -8,6 +8,13 @@ COIN_TELEGRAM_CHAT_ID=
SYMBOL=WLD SYMBOL=WLD
CHART_LOOKBACK_DAYS=365 CHART_LOOKBACK_DAYS=365
# 02 Ground Truth
GT_MIN_ORDER_KRW=5000
GT_MAX_BUY_ORDER_KRW=100000
GT_BUY_PCT_LARGE_LEG=1.0
GT_BUY_PCT_SMALL_LEG=0.05
GT_LARGE_LEG_TOP_PCT=0.2
# 04 매칭 # 04 매칭
MATCH_LABEL_MODE=leg_gt MATCH_LABEL_MODE=leg_gt
MATCH_HOLDOUT_RATIO=0.15 MATCH_HOLDOUT_RATIO=0.15
@@ -24,6 +31,8 @@ MONITOR_ALERT_KRW_AMOUNT=100000
# 3 실거래 (오픈 시에만 1) # 3 실거래 (오픈 시에만 1)
LIVE_TRADING_ENABLED=0 LIVE_TRADING_ENABLED=0
LIVE_ORDER_KRW=100000 LIVE_ORDER_KRW=100000
LIVE_BUY_PCT_LARGE=1.0
LIVE_BUY_PCT_SMALL=0.05
LIVE_DAILY_KRW_MAX=300000 LIVE_DAILY_KRW_MAX=300000
LIVE_COOLDOWN_MIN=180 LIVE_COOLDOWN_MIN=180
LIVE_MAX_TRADES_PER_DAY=10 LIVE_MAX_TRADES_PER_DAY=10

View File

@@ -202,6 +202,11 @@ GT_SELL_SPLIT_GAP_PCT = _getenv_float("GT_SELL_SPLIT_GAP_PCT", "2.5")
GT_MARKER_SIZE_MIN = _getenv_int("GT_MARKER_SIZE_MIN", "10") GT_MARKER_SIZE_MIN = _getenv_int("GT_MARKER_SIZE_MIN", "10")
GT_MARKER_SIZE_MAX = _getenv_int("GT_MARKER_SIZE_MAX", "32") GT_MARKER_SIZE_MAX = _getenv_int("GT_MARKER_SIZE_MAX", "32")
GT_INITIAL_CASH_KRW = _getenv_int("GT_INITIAL_CASH_KRW", "1000000") GT_INITIAL_CASH_KRW = _getenv_int("GT_INITIAL_CASH_KRW", "1000000")
GT_MIN_ORDER_KRW = _getenv_int("GT_MIN_ORDER_KRW", "5000")
GT_MAX_BUY_ORDER_KRW = _getenv_int("GT_MAX_BUY_ORDER_KRW", "100000")
GT_BUY_PCT_LARGE_LEG = _getenv_float("GT_BUY_PCT_LARGE_LEG", "1.0")
GT_BUY_PCT_SMALL_LEG = _getenv_float("GT_BUY_PCT_SMALL_LEG", "0.05")
GT_LARGE_LEG_TOP_PCT = _getenv_float("GT_LARGE_LEG_TOP_PCT", "0.2")
TRADING_FEE_RATE = _getenv_float("TRADING_FEE_RATE", "0.0005") TRADING_FEE_RATE = _getenv_float("TRADING_FEE_RATE", "0.0005")
# --- 모니터 / API 수집 --- # --- 모니터 / API 수집 ---
@@ -335,6 +340,8 @@ LIVE_TRADING_ENABLED = _getenv("LIVE_TRADING_ENABLED", "0").strip() in (
"yes", "yes",
) )
LIVE_ORDER_KRW = _getenv_int("LIVE_ORDER_KRW", "100000") LIVE_ORDER_KRW = _getenv_int("LIVE_ORDER_KRW", "100000")
LIVE_BUY_PCT_LARGE = _getenv_float("LIVE_BUY_PCT_LARGE", "1.0")
LIVE_BUY_PCT_SMALL = _getenv_float("LIVE_BUY_PCT_SMALL", "0.05")
LIVE_DAILY_KRW_MAX = _getenv_int("LIVE_DAILY_KRW_MAX", "300000") LIVE_DAILY_KRW_MAX = _getenv_int("LIVE_DAILY_KRW_MAX", "300000")
LIVE_COOLDOWN_MIN = _getenv_int("LIVE_COOLDOWN_MIN", "180") LIVE_COOLDOWN_MIN = _getenv_int("LIVE_COOLDOWN_MIN", "180")
LIVE_MAX_TRADES_PER_DAY = _getenv_int("LIVE_MAX_TRADES_PER_DAY", "10") LIVE_MAX_TRADES_PER_DAY = _getenv_int("LIVE_MAX_TRADES_PER_DAY", "10")

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,12 @@ from config import (
GT_BUY_BB_MAX, GT_BUY_BB_MAX,
GT_BUY_MIN_BARS, GT_BUY_MIN_BARS,
GT_BUY_MIN_SWING_PCT, GT_BUY_MIN_SWING_PCT,
GT_BUY_PCT_LARGE_LEG,
GT_BUY_PCT_SMALL_LEG,
GT_INITIAL_CASH_KRW, GT_INITIAL_CASH_KRW,
GT_LARGE_LEG_TOP_PCT,
GT_MIN_ORDER_KRW,
GT_MAX_BUY_ORDER_KRW,
GT_MAX_BUYS_PER_LEG, GT_MAX_BUYS_PER_LEG,
GT_MAX_ROUND_TRIPS, GT_MAX_ROUND_TRIPS,
TRADING_FEE_RATE, TRADING_FEE_RATE,
@@ -68,6 +73,7 @@ class TradePoint:
price: float price: float
memo: str memo: str
weight: float = 1.0 weight: float = 1.0
amount_krw: float | None = None
leg_id: int = 0 leg_id: int = 0
bb_pos: float | None = None bb_pos: float | None = None
rsi: float | None = None rsi: float | None = None
@@ -844,6 +850,12 @@ def generate_ground_truth(
) )
trade_dicts = order_trades_leg_block(trades) trade_dicts = order_trades_leg_block(trades)
trade_dicts, alloc_stats = allocate_gt_order_amounts(
trade_dicts,
initial_cash=GT_INITIAL_CASH_KRW,
min_order_krw=GT_MIN_ORDER_KRW,
fee_rate=TRADING_FEE_RATE,
)
last_close = float(df["Close"].iloc[-1]) last_close = float(df["Close"].iloc[-1])
pnl = simulate_truth_portfolio( pnl = simulate_truth_portfolio(
trade_dicts, trade_dicts,
@@ -859,8 +871,13 @@ def generate_ground_truth(
) )
_validate_leg_portfolio(trade_dicts, last_close) _validate_leg_portfolio(trade_dicts, last_close)
from deepcoin.ground_truth.gt_model import default_model, model_to_dict
gt_model = model_to_dict(default_model())
return { return {
"name": "ground_truth_split_buy_peak_sell", "name": "ground_truth_split_buy_peak_sell",
"model": gt_model,
"method": method, "method": method,
"symbol": SYMBOL, "symbol": SYMBOL,
"interval_min": ENTRY_INTERVAL, "interval_min": ENTRY_INTERVAL,
@@ -893,12 +910,23 @@ def generate_ground_truth(
"unrealized_pnl_krw": round( "unrealized_pnl_krw": round(
float(pnl.get("pnl_krw", 0)) - float(pnl_realized.get("pnl_krw", 0)), 0 float(pnl.get("pnl_krw", 0)) - float(pnl_realized.get("pnl_krw", 0)), 0
), ),
"execution_order": "leg_block", "execution_order": (
"chronological"
if any(float(t.get("amount_krw") or 0) > 0 for t in trade_dicts)
else "leg_block"
),
"order_amount_min_krw": GT_MIN_ORDER_KRW,
"order_amount_max_buy_krw": GT_MAX_BUY_ORDER_KRW,
"buy_pct_large_leg": GT_BUY_PCT_LARGE_LEG,
"buy_pct_small_leg": GT_BUY_PCT_SMALL_LEG,
"large_leg_top_pct": GT_LARGE_LEG_TOP_PCT,
**alloc_stats,
}, },
"note": ( "note": (
"저점 분할 매수(비중=삼각형), 고점 1~2회 매도. " "저점 분할 매수(비중=삼각형), 고점 1~2회 매도. "
"체결 순서=leg별 매수→매도(시각순 아님). 기간말 leg는 종가 청산. " "매수=총자산×최적비중×티어(상위 leg 대형·그 외 소형), "
"summary.pnl_pct는 미청산 포함 종가 평가, realized_pnl_pct는 체결만 반영." f"현금 한도·최소 ₩{GT_MIN_ORDER_KRW:,}. "
"체결 순서=chronological. summary.pnl_pct는 미청산 포함 종가 평가."
), ),
"trades": trade_dicts, "trades": trade_dicts,
} }
@@ -921,18 +949,6 @@ def _validate_leg_portfolio(
steps = simulate_truth_portfolio_steps(trade_dicts) steps = simulate_truth_portfolio_steps(trade_dicts)
if not steps: if not steps:
return return
leg_ids = sorted({int(s["leg_id"]) for s in steps})
for lid in leg_ids:
leg_steps = [s for s in steps if int(s["leg_id"]) == lid]
sells = [s for s in leg_steps if s["action"] == "sell"]
if not sells:
continue
last_sell = sells[-1]
if float(last_sell["holding_qty"]) > 1e-4:
raise ValueError(
f"leg#{lid} 마지막 매도 후 보유 잔존 qty={last_sell['holding_qty']} "
"(leg 블록 체결·매도 비중 합 검토 필요)"
)
final = steps[-1] final = steps[-1]
if float(final["holding_qty"]) > 1e-2: if float(final["holding_qty"]) > 1e-2:
raise ValueError( raise ValueError(
@@ -943,6 +959,203 @@ def _validate_leg_portfolio(
raise ValueError("종가 평가 후에도 미청산 보유가 남음") raise ValueError("종가 평가 후에도 미청산 보유가 남음")
def allocate_gt_order_amounts(
trades: list[dict[str, Any]],
initial_cash: float = GT_INITIAL_CASH_KRW,
min_order_krw: float = GT_MIN_ORDER_KRW,
max_buy_krw: float = GT_MAX_BUY_ORDER_KRW,
fee_rate: float = TRADING_FEE_RATE,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""
GT 각 타점에 amount_krw를 시각순·총자산·비중(최적 매수율)으로 배분합니다.
매수: 총보유자산 × (leg 비중 share × 티어 스케일), 상한=가용 현금.
leg 상위 GT_LARGE_LEG_TOP_PCT는 GT_BUY_PCT_LARGE_LEG, 그 외는 GT_BUY_PCT_SMALL_LEG.
매도 후 현금 증가분은 다음 매수부터 자동 반영(시각순 복리).
Args:
trades: trade dict 리스트(시각순 정렬 전).
initial_cash: 초기 현금.
min_order_krw: 매수·매도 최소 원화 금액.
max_buy_krw: 매수 1회 상한(가용 현금·비중 배분 후 캡).
fee_rate: 수수료율.
Returns:
(동일 dict 참조, amount_krw 채움), alloc_stats 요약.
"""
from deepcoin.matching.position_sizing import (
compute_buy_amount_krw,
leg_asset_pct_scale,
top_leg_ids_by_forward_return,
)
chron = sorted(trades, key=lambda x: x["dt"])
large_legs = top_leg_ids_by_forward_return(chron)
leg_buy_idxs: dict[int, list[int]] = {}
leg_sell_idxs: dict[int, list[int]] = {}
for i, t in enumerate(chron):
lid = int(t.get("leg_id", 0))
if t["action"] == "buy":
leg_buy_idxs.setdefault(lid, []).append(i)
elif t["action"] == "sell":
leg_sell_idxs.setdefault(lid, []).append(i)
cash = float(initial_cash)
qty = 0.0
qty_by_leg: dict[int, float] = {}
sell_leg: int | None = None
sell_base_qty = 0.0
buy_executed = 0
buy_skipped = 0
sell_executed = 0
sell_skipped = 0
buy_amounts: list[float] = []
for i, t in enumerate(chron):
price = float(t["price"])
if price <= 0:
continue
leg_id = int(t.get("leg_id", 0))
weight = float(t.get("weight", 1.0))
if t["action"] == "buy":
rem = [j for j in leg_buy_idxs.get(leg_id, []) if j >= i]
w_sum = sum(float(chron[j].get("weight", 1.0)) for j in rem)
w_share = (
weight / w_sum if w_sum > 0 else 1.0 / max(len(rem), 1)
)
scale = leg_asset_pct_scale(leg_id, large_legs)
amount = compute_buy_amount_krw(
cash,
qty,
price,
weight,
w_sum,
asset_pct_scale=scale,
min_order_krw=min_order_krw,
fee_rate=fee_rate,
)
if amount <= 0:
t["amount_krw"] = 0
buy_skipped += 1
continue
t["amount_krw"] = amount
fee = amount * fee_rate
cash -= amount + fee
bought_qty = amount / price
qty += bought_qty
qty_by_leg[leg_id] = qty_by_leg.get(leg_id, 0.0) + bought_qty
buy_executed += 1
buy_amounts.append(amount)
sell_leg = None
elif t["action"] == "sell":
leg_qty = qty_by_leg.get(leg_id, 0.0)
if leg_qty <= 1e-12:
sell_skipped += 1
continue
if sell_leg != leg_id:
sell_leg = leg_id
sell_base_qty = leg_qty
rem_sells = [j for j in leg_sell_idxs.get(leg_id, []) if j >= i]
is_last_leg_sell = bool(rem_sells) and i == rem_sells[-1]
if is_last_leg_sell:
sell_qty = leg_qty
gross = sell_qty * price
else:
gross = sell_base_qty * weight * price
if gross >= min_order_krw:
gross = max(min_order_krw, gross)
gross = min(gross, leg_qty * price)
if gross <= 0:
sell_skipped += 1
continue
if not is_last_leg_sell:
sell_qty = gross / price
else:
sell_qty = leg_qty
t["amount_krw"] = round(gross, 0)
fee = gross * fee_rate
cash += gross - fee
leg_qty -= sell_qty
qty_by_leg[leg_id] = max(leg_qty, 0.0)
qty = max(qty - sell_qty, 0.0)
if qty < 1e-12:
qty = 0.0
sell_executed += 1
stats: dict[str, Any] = {
"buy_executed": buy_executed,
"buy_skipped": buy_skipped,
"sell_executed": sell_executed,
"sell_skipped": sell_skipped,
"buy_total_krw": round(sum(buy_amounts), 0),
"large_leg_count": len(large_legs),
"large_leg_top_pct": GT_LARGE_LEG_TOP_PCT,
}
if buy_amounts:
stats["buy_amount_avg_krw"] = round(sum(buy_amounts) / len(buy_amounts), 0)
stats["buy_amount_min_krw"] = round(min(buy_amounts), 0)
stats["buy_amount_max_krw"] = round(max(buy_amounts), 0)
return trades, stats
def _resolve_sell_qty(
t: dict[str, Any],
qty: float,
price: float,
sell_base_qty: float,
weight: float,
) -> float:
"""
매도 수량: amount_krw가 보유 전량에 가깝으면 전량, 아니면 weight 비중.
Args:
t: trade dict.
qty: 현재 보유 수량.
price: 체결가.
sell_base_qty: leg 첫 매도 시점 보유량.
weight: 매도 비중.
Returns:
매도 수량.
"""
if qty <= 0 or price <= 0:
return 0.0
ak = t.get("amount_krw")
if ak is not None and float(ak) > 0:
gross_cap = float(ak)
if gross_cap >= qty * price * 0.999:
return qty
return min(qty, gross_cap / price)
return min(sell_base_qty * weight, qty)
def _trade_buy_amount(
t: dict[str, Any],
cash: float,
leg_budget: float,
current_leg: int | None,
leg_id: int,
fee_rate: float,
) -> tuple[float, float, int | None]:
"""
매수 체결 원화: amount_krw 우선, 없으면 leg_budget*weight.
Returns:
(amount, new_leg_budget, new_current_leg).
"""
weight = float(t.get("weight", 1.0))
if t.get("amount_krw") is not None and float(t["amount_krw"]) > 0:
amount = min(float(t["amount_krw"]), max(cash / (1.0 + fee_rate), 0.0))
return amount, leg_budget, current_leg
if leg_id != current_leg:
current_leg = leg_id
leg_budget = cash
amount = leg_budget * weight
return amount, leg_budget, current_leg
def order_trades_leg_block( def order_trades_leg_block(
trades: list[TradePoint] | list[dict[str, Any]], trades: list[TradePoint] | list[dict[str, Any]],
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@@ -996,8 +1209,12 @@ def _truth_simulation_rows(
Returns: Returns:
dict 행 리스트. dict 행 리스트.
""" """
if chronological: rows = [t if isinstance(t, dict) else asdict(t) for t in trades]
return order_trades_chronological(trades) use_chrono = chronological or any(
float(r.get("amount_krw") or 0) > 0 for r in rows
)
if use_chrono:
return sorted(rows, key=lambda x: x["dt"])
return order_trades_leg_block(trades) return order_trades_leg_block(trades)
@@ -1037,7 +1254,9 @@ def simulate_truth_portfolio_steps(
current_leg = leg_id current_leg = leg_id
leg_budget = cash leg_budget = cash
sell_leg = None sell_leg = None
amount = leg_budget * weight amount, leg_budget, current_leg = _trade_buy_amount(
t, cash, leg_budget, current_leg, leg_id, fee_rate
)
if amount <= 0: if amount <= 0:
continue continue
fee = amount * fee_rate fee = amount * fee_rate
@@ -1054,7 +1273,7 @@ def simulate_truth_portfolio_steps(
if leg_id != sell_leg: if leg_id != sell_leg:
sell_leg = leg_id sell_leg = leg_id
sell_base_qty = qty sell_base_qty = qty
sell_qty = min(sell_base_qty * weight, qty) sell_qty = _resolve_sell_qty(t, qty, price, sell_base_qty, weight)
if sell_qty <= 0: if sell_qty <= 0:
continue continue
gross = sell_qty * price gross = sell_qty * price
@@ -1071,6 +1290,7 @@ def simulate_truth_portfolio_steps(
"action": action, "action": action,
"price": price, "price": price,
"weight": weight, "weight": weight,
"amount_krw": t.get("amount_krw"),
"leg_id": leg_id, "leg_id": leg_id,
"cash_krw": round(cash, 0), "cash_krw": round(cash, 0),
"holding_qty": round(qty, 4), "holding_qty": round(qty, 4),
@@ -1130,7 +1350,9 @@ def simulate_truth_portfolio(
current_leg = leg_id current_leg = leg_id
leg_budget = cash leg_budget = cash
sell_leg = None sell_leg = None
amount = leg_budget * weight amount, leg_budget, current_leg = _trade_buy_amount(
t, cash, leg_budget, current_leg, leg_id, fee_rate
)
if amount <= 0: if amount <= 0:
continue continue
fee = amount * fee_rate fee = amount * fee_rate
@@ -1148,7 +1370,7 @@ def simulate_truth_portfolio(
if leg_id != sell_leg: if leg_id != sell_leg:
sell_leg = leg_id sell_leg = leg_id
sell_base_qty = qty sell_base_qty = qty
sell_qty = min(sell_base_qty * weight, qty) sell_qty = _resolve_sell_qty(t, qty, price, sell_base_qty, weight)
if sell_qty <= 0: if sell_qty <= 0:
continue continue
gross = sell_qty * price gross = sell_qty * price

View File

@@ -0,0 +1,228 @@
"""
Ground Truth 타점·비중·자본 배분 모델 (일반화 명세).
타점 생성(ground_truth.py)과 자본 배분(position_sizing.py)의 공통 언어.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from config import (
GT_BUY_BB_MAX,
GT_BUY_MIN_BARS,
GT_BUY_MIN_SWING_PCT,
GT_BUY_PCT_LARGE_LEG,
GT_BUY_PCT_SMALL_LEG,
GT_LARGE_LEG_TOP_PCT,
GT_MAX_BUYS_PER_LEG,
GT_MAX_SELLS_PER_LEG,
GT_MIN_ORDER_KRW,
GT_SELL_SPLIT_GAP_PCT,
GT_SELECTION_MODE,
)
@dataclass(frozen=True)
class GtEntrySpec:
"""
매수 타점(leg 내 분할) 규칙.
Attributes:
pivot_kind: ZigZag 저점(trough).
price_field: 체결가 = 봉 Low.
weight_rule: 저가일수록 큰 비중 (1/price 정규화).
max_per_leg: leg당 최대 매수 횟수.
min_bars_gap: 분할 매수 최소 봉 간격.
"""
pivot_kind: str = "trough"
price_field: str = "Low"
weight_rule: str = "inverse_price_normalized"
max_per_leg: int = GT_MAX_BUYS_PER_LEG
min_bars_gap: int = GT_BUY_MIN_BARS
bb_filter: str = f"bb_pos <= {GT_BUY_BB_MAX}"
@dataclass(frozen=True)
class GtExitSpec:
"""
매도 타점(leg 내 분할) 규칙.
Attributes:
pivot_kind: major swing 고점(peak).
price_field: 체결가 = 봉 High.
split_weights: 2회 분할 시 (65%, 35%).
split_gap_pct: 2차 고점 인정 최소 괴리(%).
"""
pivot_kind: str = "peak"
price_field: str = "High"
split_weights: tuple[float, float] = (0.65, 0.35)
split_gap_pct: float = GT_SELL_SPLIT_GAP_PCT
max_per_leg: int = GT_MAX_SELLS_PER_LEG
@dataclass(frozen=True)
class GtCapitalSpec:
"""
체결 원화(amount_krw) 배분 규칙.
Attributes:
buy_formula: 총자산 × 최적매수율, 상한=가용현금.
optimal_buy_rate: leg 내 남은 weight 비중.
large_leg_top_pct: 수익률 상위 leg 비율.
pct_large: 상위 leg 총자산 배분 스케일.
pct_small: 그 외 leg 스케일.
min_order_krw: 최소 체결 원화.
"""
buy_formula: str = "min(total_asset * w_share * tier_scale, cash/(1+fee))"
optimal_buy_rate: str = "weight / sum(remaining_buy_weights_in_leg)"
large_leg_top_pct: float = GT_LARGE_LEG_TOP_PCT
pct_large: float = GT_BUY_PCT_LARGE_LEG
pct_small: float = GT_BUY_PCT_SMALL_LEG
min_order_krw: float = float(GT_MIN_ORDER_KRW)
sell_formula: str = "leg_qty * sell_weight * price (last sell = full leg_qty)"
@dataclass
class GroundTruthModel:
"""
GT 전체 모델 (타점 + 비중 + 자본).
Attributes:
selection_mode: 타점 생성 모드.
entry: 매수 명세.
exit: 매도 명세.
capital: 자본 배분 명세.
leg: leg 정의.
"""
selection_mode: str = GT_SELECTION_MODE
entry: GtEntrySpec = field(default_factory=GtEntrySpec)
exit: GtExitSpec = field(default_factory=GtExitSpec)
capital: GtCapitalSpec = field(default_factory=GtCapitalSpec)
leg_definition: str = (
"이전 고점 매도 ~ 다음 고점 매도 구간 = leg_id; "
"기간말 잔여 구간은 마지막 leg"
)
execution_order_chrono: str = (
"amount_krw 배분·summary.pnl_pct = 시각순 체결(매도 후 현금 → 다음 매수 반영)"
)
execution_order_leg_block: str = (
"JSON 저장 순서 = leg별 매수 전량 → 매도 전량 (차트·테이블 leg 정합)"
)
def default_model() -> GroundTruthModel:
"""현재 config 기준 GT 모델."""
return GroundTruthModel()
def compute_buy_weights_inverse_price(prices: list[float]) -> list[float]:
"""
저점 매수 비중: score_i = 1/price_i → 합=1 정규화.
Args:
prices: leg 내 매수 후보 가격.
Returns:
weight 리스트 (합 ≈ 1).
"""
if not prices:
return []
scores = [1.0 / max(p, 1e-9) for p in prices]
s = sum(scores)
return [x / s for x in scores] if s > 0 else [1.0 / len(prices)] * len(prices)
def sell_split_weights(n_sells: int) -> list[float]:
"""
leg 매도 비중.
Args:
n_sells: 매도 횟수(1 또는 2).
Returns:
weight 리스트.
"""
spec = GtExitSpec()
if n_sells >= 2:
return list(spec.split_weights)
return [1.0]
def model_to_dict(model: GroundTruthModel | None = None) -> dict[str, Any]:
"""
JSON·리포트용 모델 dict.
Args:
model: None이면 default.
Returns:
직렬화 dict.
"""
m = model or default_model()
return {
"selection_mode": m.selection_mode,
"leg_definition": m.leg_definition,
"entry": {
"pivot": m.entry.pivot_kind,
"price": m.entry.price_field,
"weight_rule": m.entry.weight_rule,
"weight_formula": "w_i = (1/price_i) / sum(1/price_j)",
"max_buys_per_leg": m.entry.max_per_leg,
"min_bars_between_buys": m.entry.min_bars_gap,
"bb_filter": m.entry.bb_filter,
},
"exit": {
"pivot": m.exit.pivot_kind,
"price": m.exit.price_field,
"weight_rule": "fixed_split_or_full",
"weights_two_sell": list(m.exit.split_weights),
"split_gap_pct": m.exit.split_gap_pct,
"max_sells_per_leg": m.exit.max_per_leg,
},
"capital": {
"buy": m.capital.buy_formula,
"optimal_buy_rate": m.capital.optimal_buy_rate,
"large_leg_top_pct": m.capital.large_leg_top_pct,
"pct_large_leg": m.capital.pct_large,
"pct_small_leg": m.capital.pct_small,
"min_order_krw": m.capital.min_order_krw,
"sell": m.capital.sell_formula,
},
"execution": {
"chrono": m.execution_order_chrono,
"leg_block_json": m.execution_order_leg_block,
},
}
def summarize_leg_weights(trades: list[dict[str, Any]]) -> dict[str, Any]:
"""
leg별 매수·매도 비중 합 검증용 요약.
Args:
trades: GT trade dict.
Returns:
leg_id → {buy_sum, sell_sum, n_buy, n_sell}.
"""
legs: dict[int, dict[str, Any]] = {}
for t in trades:
lid = int(t.get("leg_id", 0))
legs.setdefault(
lid,
{"buy_sum": 0.0, "sell_sum": 0.0, "n_buy": 0, "n_sell": 0},
)
w = float(t.get("weight", 0))
if t.get("action") == "buy":
legs[lid]["buy_sum"] += w
legs[lid]["n_buy"] += 1
elif t.get("action") == "sell":
legs[lid]["sell_sum"] += w
legs[lid]["n_sell"] += 1
return legs

View File

@@ -15,9 +15,39 @@ from config import (
LIVE_ORDER_KRW, LIVE_ORDER_KRW,
TRADING_FEE_RATE, TRADING_FEE_RATE,
) )
from deepcoin.matching.position_sizing import (
attach_dynamic_buy_amounts,
load_sizing_context_from_gt,
)
def select_capped_fires(fires: pd.DataFrame) -> pd.DataFrame: def _planned_order_krw(
t: dict[str, Any],
order_krw: float,
sizing_mode: str,
) -> float:
"""
체결 계획 원화: amount_krw 우선 또는 고정.
Args:
t: trade dict.
order_krw: 고정 1회 금액.
sizing_mode: fixed | amount_krw.
Returns:
계획 원화.
"""
ak = t.get("amount_krw")
if sizing_mode == "amount_krw" or (ak is not None and float(ak) > 0):
return float(ak or 0)
return float(order_krw)
def select_capped_fires(
fires: pd.DataFrame,
*,
use_dynamic_sizing: bool = True,
) -> pd.DataFrame:
""" """
일한도·회수 제한으로 체결 가능한 발화만 남깁니다. 일한도·회수 제한으로 체결 가능한 발화만 남깁니다.
@@ -29,20 +59,58 @@ def select_capped_fires(fires: pd.DataFrame) -> pd.DataFrame:
""" """
if fires.empty: if fires.empty:
return fires return fires
gt_trades, large_legs, approved = load_sizing_context_from_gt()
df = fires.sort_values("dt").copy() df = fires.sort_values("dt").copy()
df["ts"] = pd.to_datetime(df["dt"]) df["ts"] = pd.to_datetime(df["dt"])
df["day"] = df["ts"].dt.date.astype(str) df["day"] = df["ts"].dt.date.astype(str)
cash = float(GT_INITIAL_CASH_KRW)
qty = 0.0
taken: list[pd.DataFrame] = [] taken: list[pd.DataFrame] = []
for _, day_grp in df.groupby("day", sort=True): for _, day_grp in df.groupby("day", sort=True):
spent = 0.0 spent = 0.0
n_trades = 0 n_trades = 0
idxs: list[Any] = [] idxs: list[Any] = []
for idx, _row in day_grp.iterrows(): for idx, row in day_grp.iterrows():
if n_trades >= LIVE_MAX_TRADES_PER_DAY: if n_trades >= LIVE_MAX_TRADES_PER_DAY:
break break
if spent + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX: 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 break
spent += LIVE_ORDER_KRW 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 n_trades += 1
idxs.append(idx) idxs.append(idx)
if idxs: if idxs:
@@ -52,7 +120,11 @@ def select_capped_fires(fires: pd.DataFrame) -> pd.DataFrame:
return pd.concat(taken, ignore_index=True) return pd.concat(taken, ignore_index=True)
def fires_to_trade_list(fires: pd.DataFrame) -> list[dict[str, Any]]: def fires_to_trade_list(
fires: pd.DataFrame,
*,
apply_dynamic_sizing: bool = True,
) -> list[dict[str, Any]]:
""" """
발화 DataFrame을 포트폴리오 시뮬용 trade dict 리스트로 변환. 발화 DataFrame을 포트폴리오 시뮬용 trade dict 리스트로 변환.
@@ -73,25 +145,65 @@ def fires_to_trade_list(fires: pd.DataFrame) -> list[dict[str, Any]]:
"forward_ret_pct": float(r.get("forward_ret_pct", 0)), "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(
rows,
gt_trades=gt_trades,
approved_rules=approved,
large_legs=large_legs,
)
return rows return rows
def simulate_sized_portfolio(
trades: list[dict[str, Any]],
initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE,
last_price: float | None = None,
fallback_order_krw: float = LIVE_ORDER_KRW,
) -> dict[str, Any]:
"""
trade.amount_krw(총자산 비율 배분) 기준 포트폴리오 시뮬.
Args:
trades: 시간순 trade dict (amount_krw 권장).
initial_cash: 시작 현금.
fee_rate: 수수료율.
last_price: 미청산 평가 종가.
fallback_order_krw: amount_krw 없을 때 1회 금액.
Returns:
simulate_truth_portfolio와 동일 키 구조.
"""
return simulate_fixed_order_portfolio(
trades,
order_krw=fallback_order_krw,
initial_cash=initial_cash,
fee_rate=fee_rate,
last_price=last_price,
sizing_mode="amount_krw",
)
def simulate_fixed_order_portfolio( def simulate_fixed_order_portfolio(
trades: list[dict[str, Any]], trades: list[dict[str, Any]],
order_krw: float = LIVE_ORDER_KRW, order_krw: float = LIVE_ORDER_KRW,
initial_cash: float = GT_INITIAL_CASH_KRW, initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE, fee_rate: float = TRADING_FEE_RATE,
last_price: float | None = None, last_price: float | None = None,
sizing_mode: str = "fixed",
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
매 체결마다 고정 원화 금액으로 매수·매도한 뒤 총평가·수익률을 계산합니다. 포트폴리오 시뮬 (고정 원화 또는 trade.amount_krw).
Args: Args:
trades: 시간순 {dt, action, price}. trades: 시간순 {dt, action, price, amount_krw?}.
order_krw: 1회 매수·매도 금액(원). order_krw: sizing_mode=fixed 일 때 1회 금액(원).
initial_cash: 시작 현금. initial_cash: 시작 현금.
fee_rate: 수수료율. fee_rate: 수수료율.
last_price: 미청산 평가 종가. last_price: 미청산 평가 종가.
sizing_mode: 'fixed' | 'amount_krw' (없으면 order_krw).
Returns: Returns:
simulate_truth_portfolio와 동일 키 구조. simulate_truth_portfolio와 동일 키 구조.
@@ -110,7 +222,8 @@ def simulate_fixed_order_portfolio(
last_trade_price = price last_trade_price = price
if action == "buy": if action == "buy":
amount = min(order, max(cash / (1.0 + fee_rate), 0.0)) planned = _planned_order_krw(t, order, sizing_mode)
amount = min(planned, max(cash / (1.0 + fee_rate), 0.0))
if amount <= 0: if amount <= 0:
continue continue
fee = amount * fee_rate fee = amount * fee_rate
@@ -119,7 +232,11 @@ def simulate_fixed_order_portfolio(
qty += amount / price qty += amount / price
elif action == "sell" and qty > 0: elif action == "sell" and qty > 0:
sell_qty = min(qty, order / price) planned = _planned_order_krw(t, order, sizing_mode)
if planned >= qty * price * 0.999:
sell_qty = qty
else:
sell_qty = min(qty, planned / price)
if sell_qty <= 0: if sell_qty <= 0:
continue continue
gross = sell_qty * price gross = sell_qty * price
@@ -148,6 +265,7 @@ def simulate_fixed_order_portfolio(
"mark_price": round(mark_price, 2), "mark_price": round(mark_price, 2),
"fee_rate": fee_rate, "fee_rate": fee_rate,
"order_krw": round(order, 0), "order_krw": round(order, 0),
"sizing_mode": sizing_mode,
"trade_count": len(trades), "trade_count": len(trades),
} }
@@ -157,6 +275,7 @@ def simulate_fixed_order_portfolio_steps(
order_krw: float = LIVE_ORDER_KRW, order_krw: float = LIVE_ORDER_KRW,
initial_cash: float = GT_INITIAL_CASH_KRW, initial_cash: float = GT_INITIAL_CASH_KRW,
fee_rate: float = TRADING_FEE_RATE, fee_rate: float = TRADING_FEE_RATE,
sizing_mode: str = "fixed",
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
체결마다 현금·보유·총평가 스냅샷 (GT 테이블용). 체결마다 현금·보유·총평가 스냅샷 (GT 테이블용).
@@ -166,6 +285,7 @@ def simulate_fixed_order_portfolio_steps(
order_krw: 1회 체결 원화. order_krw: 1회 체결 원화.
initial_cash: 시작 현금. initial_cash: 시작 현금.
fee_rate: 수수료율. fee_rate: 수수료율.
sizing_mode: fixed | amount_krw.
Returns: Returns:
step dict 리스트. step dict 리스트.
@@ -182,7 +302,8 @@ def simulate_fixed_order_portfolio_steps(
continue continue
if action == "buy": if action == "buy":
amount = min(order, max(cash / (1.0 + fee_rate), 0.0)) planned = _planned_order_krw(t, order, sizing_mode)
amount = min(planned, max(cash / (1.0 + fee_rate), 0.0))
if amount <= 0: if amount <= 0:
continue continue
fee = amount * fee_rate fee = amount * fee_rate
@@ -190,7 +311,11 @@ def simulate_fixed_order_portfolio_steps(
qty += amount / price qty += amount / price
elif action == "sell" and qty > 0: elif action == "sell" and qty > 0:
sell_qty = min(qty, order / price) planned = _planned_order_krw(t, order, sizing_mode)
if planned >= qty * price * 0.999:
sell_qty = qty
else:
sell_qty = min(qty, planned / price)
if sell_qty <= 0: if sell_qty <= 0:
continue continue
gross = sell_qty * price gross = sell_qty * price
@@ -207,6 +332,7 @@ def simulate_fixed_order_portfolio_steps(
"price": price, "price": price,
"rule_id": t.get("rule_id", ""), "rule_id": t.get("rule_id", ""),
"forward_ret_pct": t.get("forward_ret_pct"), "forward_ret_pct": t.get("forward_ret_pct"),
"amount_krw": t.get("amount_krw"),
"cash_krw": round(cash, 0), "cash_krw": round(cash, 0),
"holding_qty": round(qty, 4), "holding_qty": round(qty, 4),
"total_asset_krw": round(cash + qty * price, 0), "total_asset_krw": round(cash + qty * price, 0),

View File

@@ -0,0 +1,378 @@
"""
총자산 대비 최적 매수율(비중) · 현금 한도 · 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

View File

@@ -12,6 +12,7 @@ import numpy as np
import pandas as pd import pandas as pd
from config import ( from config import (
GT_INITIAL_CASH_KRW,
LIVE_DAILY_KRW_MAX, LIVE_DAILY_KRW_MAX,
LIVE_MAX_TRADES_PER_DAY, LIVE_MAX_TRADES_PER_DAY,
LIVE_ORDER_KRW, LIVE_ORDER_KRW,
@@ -28,7 +29,19 @@ from config import (
SIM_WALK_FORWARD_MIN_MONTHS, SIM_WALK_FORWARD_MIN_MONTHS,
TRADING_FEE_RATE, TRADING_FEE_RATE,
) )
from deepcoin.ground_truth.ground_truth import (
load_ground_truth,
order_trades_chronological,
simulate_truth_portfolio,
)
from deepcoin.matching.portfolio_sim import (
fires_to_trade_list,
select_capped_fires,
simulate_fixed_order_portfolio,
simulate_sized_portfolio,
)
from deepcoin.matching.select_rules import _rule_metrics, _split_train_valid_holdout from deepcoin.matching.select_rules import _rule_metrics, _split_train_valid_holdout
from deepcoin.paths import resolve_ground_truth_file
from deepcoin.paths import ( from deepcoin.paths import (
ANALYSIS_GT_CALIBRATION_JSON, ANALYSIS_GT_CALIBRATION_JSON,
MATCHING_FIRE_OUTCOMES, MATCHING_FIRE_OUTCOMES,
@@ -109,12 +122,19 @@ def walk_forward_summary(wf_rows: list[dict[str, Any]]) -> dict[str, Any]:
return out return out
def simulate_live_order_cap(outcomes: pd.DataFrame) -> dict[str, Any]: def simulate_live_order_cap(
outcomes: pd.DataFrame,
*,
rule_ids: set[str] | None = None,
holdout_only: bool = True,
) -> dict[str, Any]:
""" """
1회·일 한도·슬리피지 가정으로 체결 가능한 발화만 집계. 1회·일 한도·슬리피지 가정으로 체결 가능한 발화만 집계.
Args: Args:
outcomes: fire_outcomes. outcomes: fire_outcomes (split 컬럼 있으면 holdout 필터 가능).
rule_ids: None이면 전 규칙, 지정 시 해당 rule만.
holdout_only: True면 split==holdout 만.
Returns: Returns:
규칙별·전체 요약. 규칙별·전체 요약.
@@ -122,12 +142,27 @@ def simulate_live_order_cap(outcomes: pd.DataFrame) -> dict[str, Any]:
if outcomes.empty: if outcomes.empty:
return {"rules": {}, "note": "발화 없음"} return {"rules": {}, "note": "발화 없음"}
df = outcomes.sort_values("dt").copy() df = outcomes
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["ts"] = pd.to_datetime(df["dt"])
df["day"] = df["ts"].dt.date.astype(str) df["day"] = df["ts"].dt.date.astype(str)
slip = LIVE_SLIPPAGE_PCT slip = LIVE_SLIPPAGE_PCT
taken_rows: list[pd.DataFrame] = [] 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,
)
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): for day, day_grp in df.groupby("day", sort=True):
spent = 0.0 spent = 0.0
n_trades = 0 n_trades = 0
@@ -135,9 +170,34 @@ def simulate_live_order_cap(outcomes: pd.DataFrame) -> dict[str, Any]:
for idx, row in day_grp.iterrows(): for idx, row in day_grp.iterrows():
if n_trades >= LIVE_MAX_TRADES_PER_DAY: if n_trades >= LIVE_MAX_TRADES_PER_DAY:
break break
if spent + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX: 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 break
spent += LIVE_ORDER_KRW 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 n_trades += 1
taken_idx.append(idx) taken_idx.append(idx)
if taken_idx: if taken_idx:
@@ -164,6 +224,7 @@ def simulate_live_order_cap(outcomes: pd.DataFrame) -> dict[str, Any]:
"order_krw": LIVE_ORDER_KRW, "order_krw": LIVE_ORDER_KRW,
"daily_krw_max": LIVE_DAILY_KRW_MAX, "daily_krw_max": LIVE_DAILY_KRW_MAX,
"slippage_pct": slip, "slippage_pct": slip,
"sizing": "total_asset_pct_ev_wf_large_leg",
}, },
"taken_count": int(len(taken)), "taken_count": int(len(taken)),
"total_count": int(len(df)), "total_count": int(len(df)),
@@ -267,9 +328,35 @@ def build_simulation_report(
sub.assign(forward_ret_pct=adj) sub.assign(forward_ret_pct=adj)
) )
live_cap = simulate_live_order_cap(outcomes) monitor_ids = {r["rule_id"] for r in matched.get("monitor_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) go = evaluate_go_no_go(matched, wf_sum, fee_stress, live_cap)
portfolio_compare: dict[str, Any] = {}
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),
last_price=float(mark) if mark else None,
)
holdout = outcomes[
outcomes["rule_id"].isin(monitor_ids) & (outcomes["split"] == "holdout")
]
capped = select_capped_fires(holdout)
if not capped.empty:
portfolio_compare["sim_sized"] = simulate_sized_portfolio(
fires_to_trade_list(capped, apply_dynamic_sizing=True),
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),
last_price=float(mark) if mark else None,
)
gt_portfolio: dict[str, Any] = {} gt_portfolio: dict[str, Any] = {}
if ANALYSIS_GT_CALIBRATION_JSON.is_file(): if ANALYSIS_GT_CALIBRATION_JSON.is_file():
cal = json.loads(ANALYSIS_GT_CALIBRATION_JSON.read_text(encoding="utf-8")) cal = json.loads(ANALYSIS_GT_CALIBRATION_JSON.read_text(encoding="utf-8"))
@@ -301,6 +388,8 @@ def build_simulation_report(
"fee_stress_by_rule": fee_stress, "fee_stress_by_rule": fee_stress,
"live_order_cap_sim": live_cap, "live_order_cap_sim": live_cap,
"go_no_go": go, "go_no_go": go,
"portfolio_compare": portfolio_compare,
"gt_model": gt_data.get("model"),
"monitor_rules": matched.get("monitor_rules", []), "monitor_rules": matched.get("monitor_rules", []),
"gt_portfolio_calibration": gt_portfolio, "gt_portfolio_calibration": gt_portfolio,
"criteria": { "criteria": {

View File

@@ -20,6 +20,7 @@ from config import (
) )
from deepcoin.ground_truth.ground_truth import ( from deepcoin.ground_truth.ground_truth import (
load_ground_truth, load_ground_truth,
order_trades_chronological,
simulate_truth_portfolio, simulate_truth_portfolio,
simulate_truth_portfolio_steps, simulate_truth_portfolio_steps,
) )
@@ -28,6 +29,7 @@ from deepcoin.matching.portfolio_sim import (
select_capped_fires, select_capped_fires,
simulate_fixed_order_portfolio, simulate_fixed_order_portfolio,
simulate_fixed_order_portfolio_steps, simulate_fixed_order_portfolio_steps,
simulate_sized_portfolio,
) )
from deepcoin.matching.select_rules import _split_train_valid_holdout from deepcoin.matching.select_rules import _split_train_valid_holdout
from deepcoin.ops.chart_report import ( from deepcoin.ops.chart_report import (
@@ -36,6 +38,7 @@ from deepcoin.ops.chart_report import (
market_cards_html, market_cards_html,
pnl_cards_html, pnl_cards_html,
rule_criteria_html, rule_criteria_html,
stacked_summary_cards_html,
wrap_chart_report_page, wrap_chart_report_page,
) )
from deepcoin.ops.simulation import build_chart_html, load_chart_frames, _frames_to_mtf from deepcoin.ops.simulation import build_chart_html, load_chart_frames, _frames_to_mtf
@@ -194,9 +197,11 @@ def _summary_cards_html(
bb_txt: str, bb_txt: str,
gt_trades: list[dict[str, Any]], gt_trades: list[dict[str, Any]],
gt_pnl: dict[str, Any], gt_pnl: dict[str, Any],
sim_pnl: dict[str, Any], sim_sized_pnl: dict[str, Any],
sim_fixed_pnl: dict[str, Any],
sim_trade_count: int, sim_trade_count: int,
go_flag: bool, go_flag: bool,
model_note: str = "",
) -> str: ) -> str:
""" """
ground_truth HTML과 동일 구성의 상단 카드 (GT + 시뮬 2줄). ground_truth HTML과 동일 구성의 상단 카드 (GT + 시뮬 2줄).
@@ -206,26 +211,43 @@ def _summary_cards_html(
bb_txt: BB %B. bb_txt: BB %B.
gt_trades: GT trades. gt_trades: GT trades.
gt_pnl: GT 포트폴리오 요약. gt_pnl: GT 포트폴리오 요약.
sim_pnl: 시뮬 포트폴리오 요약. sim_sized_pnl: 총자산%·EV/WF·leg 시뮬 요약.
sim_fixed_pnl: 고정 ₩/회 baseline.
sim_trade_count: 체결 가정 발화 수. sim_trade_count: 체결 가정 발화 수.
go_flag: Go/No-Go. go_flag: Go/No-Go.
model_note: GT 모델 한 줄 요약.
Returns: Returns:
cards HTML. cards HTML.
""" """
go_cls = "go-pass" if go_flag else "go-fail" go_cls = "go-pass" if go_flag else "go-fail"
gt_row = ( gt_sub = (
'<p class="cards-group-title">정답 (ground_truth) — 분할 비중·leg 체결</p>' "저점 분할매수(1/price 비중) · 고점 65/35% 매도 · "
+ market_cards_html(close_last, bb_txt) "총자산×비중×leg티어 · 시각순 복리"
+ pnl_cards_html(gt_pnl, "정답 타점", len(gt_trades))
) )
sim_row = ( if model_note:
'<p class="cards-group-title">시뮬 (monitor_rules · holdout · ' gt_sub = model_note
f"1회 ₩{LIVE_ORDER_KRW:,.0f}·일한도) — " gt_cards = market_cards_html(close_last, bb_txt) + pnl_cards_html(
f'<span class="{go_cls}">{"GO" if go_flag else "NO-GO"}</span></p>' gt_pnl, "정답 GT", len(gt_trades)
+ pnl_cards_html(sim_pnl, "시뮬 체결", sim_trade_count) )
sim_sized_title = (
"시뮬·총자산% (EV/WF·leg상위) — "
f'<span class="{go_cls}">{"GO" if go_flag else "NO-GO"}</span>'
)
sim_fixed_title = f"시뮬·고정 ₩{LIVE_ORDER_KRW:,}/회 (비교)"
return (
'<div class="summary-cards">'
+ stacked_summary_cards_html(gt_sub, gt_cards)
+ stacked_summary_cards_html(
sim_sized_title,
pnl_cards_html(sim_sized_pnl, "시뮬(비율)", sim_trade_count),
)
+ stacked_summary_cards_html(
sim_fixed_title,
pnl_cards_html(sim_fixed_pnl, "시뮬(고정)", sim_trade_count),
)
+ "</div>"
) )
return gt_row + sim_row
def build_simulation_page_html( def build_simulation_page_html(
@@ -294,56 +316,57 @@ def build_simulation_page_html(
elif gt_summary.get("mark_price"): elif gt_summary.get("mark_price"):
close_val = float(gt_summary["mark_price"]) close_val = float(gt_summary["mark_price"])
sim_trades = fires_to_trade_list(capped) sim_trades_sized = fires_to_trade_list(capped, apply_dynamic_sizing=True)
gt_pnl = {} sim_trades_fixed = fires_to_trade_list(capped, apply_dynamic_sizing=False)
gt_summary_pnl = gt_data.get("summary") or {}
if gt_summary_pnl.get("pnl_krw") is not None and gt_summary_pnl.get( gt_pnl: dict[str, Any] = {}
"execution_order" if gt_trades:
) == "leg_block": gt_chron = order_trades_chronological(gt_trades)
gt_pnl = {
k: gt_summary_pnl[k]
for k in (
"initial_cash_krw",
"final_asset_krw",
"pnl_pct",
"total_fees_krw",
"holding_qty",
"holding_value_krw",
"mark_price",
"cash_krw",
)
if k in gt_summary_pnl
}
elif gt_trades:
gt_pnl = simulate_truth_portfolio( gt_pnl = simulate_truth_portfolio(
gt_trades, gt_chron,
initial_cash=GT_INITIAL_CASH_KRW, initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE, fee_rate=TRADING_FEE_RATE,
last_price=close_val if close_val else None, last_price=close_val if close_val else None,
) )
sim_pnl = simulate_fixed_order_portfolio( mark = close_val if close_val else None
sim_trades, sim_sized_pnl = simulate_sized_portfolio(
sim_trades_sized,
initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE,
last_price=mark,
)
sim_fixed_pnl = simulate_fixed_order_portfolio(
sim_trades_fixed,
order_krw=LIVE_ORDER_KRW, order_krw=LIVE_ORDER_KRW,
initial_cash=GT_INITIAL_CASH_KRW, initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE, fee_rate=TRADING_FEE_RATE,
last_price=close_val if close_val else None, last_price=mark,
sizing_mode="fixed",
) )
sim_steps = simulate_fixed_order_portfolio_steps( sim_steps = simulate_fixed_order_portfolio_steps(
sim_trades, sim_trades_sized,
order_krw=LIVE_ORDER_KRW, order_krw=LIVE_ORDER_KRW,
initial_cash=GT_INITIAL_CASH_KRW, initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE, fee_rate=TRADING_FEE_RATE,
sizing_mode="amount_krw",
) )
gt_steps = ( gt_steps = (
simulate_truth_portfolio_steps( simulate_truth_portfolio_steps(
gt_trades, order_trades_chronological(gt_trades),
initial_cash=GT_INITIAL_CASH_KRW, initial_cash=GT_INITIAL_CASH_KRW,
fee_rate=TRADING_FEE_RATE, fee_rate=TRADING_FEE_RATE,
) )
if gt_trades if gt_trades
else [] else []
) )
model = gt_data.get("model") or {}
model_note = (
f"mode={model.get('selection_mode', 'split_buy_peak_sell')} · "
f"매수비중=1/price · 매도=65/35%"
if model
else ""
)
criteria_blocks = "".join(rule_criteria_html(r) for r in monitor_rules) criteria_blocks = "".join(rule_criteria_html(r) for r in monitor_rules)
go_table = go_no_go_table_html(go.get("checks", []), go_flag) go_table = go_no_go_table_html(go.get("checks", []), go_flag)
@@ -355,7 +378,7 @@ def build_simulation_page_html(
sim_table = f""" sim_table = f"""
<h2>시뮬 타점 (holdout {len(holdout_fires)}건 → 체결 가정 {len(capped)}건)</h2> <h2>시뮬 타점 (holdout {len(holdout_fires)}건 → 체결 가정 {len(capped)}건)</h2>
<p class="meta">1회 ₩{LIVE_ORDER_KRW:,.0f}·일한도·최대 거래수 적용 후 체결 순 포트폴리오. <p class="meta">총자산×최적비중·현금한도·EV/WF통과·leg상위 대형 매수. 일한도·최대 거래수 적용.
가격 열 (+/-) = <b>{label_mode}</b> 구간 수익%.{_mark_note(close_val)}</p> 가격 열 (+/-) = <b>{label_mode}</b> 구간 수익%.{_mark_note(close_val)}</p>
<div class="table-scroll"> <div class="table-scroll">
<table> <table>
@@ -367,7 +390,7 @@ def build_simulation_page_html(
gt_table = f""" gt_table = f"""
<h2>정답 타점 (ground_truth)</h2> <h2>정답 타점 (ground_truth)</h2>
<p class="meta">삼각형 = GT. 매수 분할 비중·매도 leg 반영.{_mark_note(close_val)}</p> <p class="meta">삼각형 크기 = GT 체결 금액. 매수 분할·매도 leg 반영.{_mark_note(close_val)}</p>
<div class="table-scroll"> <div class="table-scroll">
<table> <table>
<thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead> <thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead>
@@ -390,7 +413,7 @@ def build_simulation_page_html(
"상단 카드: 초기 금액·총보유자산·초기 대비 증감율·수수료." "상단 카드: 초기 금액·총보유자산·초기 대비 증감율·수수료."
) )
legend = ( legend = (
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 = GT 비중.<br>" "▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 = GT 체결 금액.<br>"
"● <b>시뮬</b> — 원 = holdout 발화 (차트). 테이블 = 일한도 적용 체결 순서." "● <b>시뮬</b> — 원 = holdout 발화 (차트). 테이블 = 일한도 적용 체결 순서."
) )
if frames is not None: if frames is not None:
@@ -405,7 +428,15 @@ def build_simulation_page_html(
) )
cards = _summary_cards_html( cards = _summary_cards_html(
close_val, bb_txt, gt_trades, gt_pnl, sim_pnl, len(capped), go_flag close_val,
bb_txt,
gt_trades,
gt_pnl,
sim_sized_pnl,
sim_fixed_pnl,
len(capped),
go_flag,
model_note=model_note,
) )
if frames is not None: if frames is not None:
@@ -415,6 +446,7 @@ def build_simulation_page_html(
note=note, note=note,
truth_trades=gt_trades, truth_trades=gt_trades,
sim_trades=_fires_to_chart_trades(holdout_fires), sim_trades=_fires_to_chart_trades(holdout_fires),
# 차트 마커는 holdout 전체; 카드·테이블은 일한도 capped
title_suffix="1단계 시뮬레이션 (monitor · holdout)", title_suffix="1단계 시뮬레이션 (monitor · holdout)",
legend_html=legend, legend_html=legend,
footer_sections=sections, footer_sections=sections,

View File

@@ -37,7 +37,13 @@ CHART_REPORT_CSS = """
.table-scroll { max-height: 480px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 8px; } .table-scroll { max-height: 480px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 8px; }
.pass { color: #16a34a; font-weight: 600; } .pass { color: #16a34a; font-weight: 600; }
.fail { color: #dc2626; font-weight: 600; } .fail { color: #dc2626; font-weight: 600; }
.cards-group-title { font-size: 0.82rem; color: #475569; margin: 14px 0 6px; font-weight: 600; } .summary-cards { margin: 16px 0; }
.summary-cards .cards-row-block { display: block; width: 100%; margin-bottom: 14px; }
.summary-cards .cards-row-block:last-child { margin-bottom: 0; }
.cards-group-title {
font-size: 0.82rem; color: #475569; margin: 0 0 8px; font-weight: 600;
display: block;
}
""" """
@@ -116,6 +122,28 @@ def card_html(label: str, value: str) -> str:
return f'<div class="card"><span>{label}</span><b>{value}</b></div>' return f'<div class="card"><span>{label}</span><b>{value}</b></div>'
def stacked_summary_cards_html(
title: str,
cards_inner: str,
) -> str:
"""
제목 한 줄 + 카드 flex 한 줄을 세로 블록으로 묶습니다.
Args:
title: cards-group-title 텍스트(HTML 허용).
cards_inner: .cards 안에 넣을 card div 문자열.
Returns:
cards-row-block HTML.
"""
return (
'<div class="cards-row-block">'
f'<p class="cards-group-title">{title}</p>'
f'<div class="cards">{cards_inner}</div>'
"</div>"
)
def wrap_chart_report_page( def wrap_chart_report_page(
page_title: str, page_title: str,
heading: str, heading: str,
@@ -135,7 +163,7 @@ def wrap_chart_report_page(
meta_line: 기간·추세 등. meta_line: 기간·추세 등.
note_html: 안내 박스. note_html: 안내 박스.
legend_html: 차트 범례 설명. legend_html: 차트 범례 설명.
cards_html: .cards 내부 HTML. cards_html: .cards 내부 HTML 또는 .summary-cards 블록 전체.
chart_html: plotly embed. chart_html: plotly embed.
sections_html: h2·테이블·criteria 등 본문 하단. sections_html: h2·테이블·criteria 등 본문 하단.
@@ -154,7 +182,7 @@ def wrap_chart_report_page(
<p class="meta">{meta_line}</p> <p class="meta">{meta_line}</p>
{note_html} {note_html}
<div class="legend-box">{legend_html}</div> <div class="legend-box">{legend_html}</div>
<div class="cards">{cards_html}</div> {cards_html if "summary-cards" in cards_html else f'<div class="cards">{cards_html}</div>'}
<div class="chart-wrap">{chart_html}</div> <div class="chart-wrap">{chart_html}</div>
{sections_html} {sections_html}
</body> </body>

View File

@@ -19,9 +19,18 @@ from config import (
LIVE_ORDER_KRW, LIVE_ORDER_KRW,
LIVE_TRADING_ENABLED, LIVE_TRADING_ENABLED,
SYMBOL, SYMBOL,
TRADING_FEE_RATE,
) )
from deepcoin.ground_truth.ground_truth import load_ground_truth
from deepcoin.matching.live_eval import evaluate_live_rules from deepcoin.matching.live_eval import evaluate_live_rules
from deepcoin.matching.load_rules import load_monitor_rules from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.matching.position_sizing import (
compute_buy_amount_krw,
live_buy_asset_pct_scale,
load_ev_wf_approved_rule_ids,
top_leg_ids_by_forward_return,
)
from deepcoin.paths import resolve_ground_truth_file
from deepcoin.ops.alert_message import build_rule_alert_message from deepcoin.ops.alert_message import build_rule_alert_message
from deepcoin.ops.monitor import Monitor from deepcoin.ops.monitor import Monitor
from deepcoin.paths import LIVE_TRADES_LOG from deepcoin.paths import LIVE_TRADES_LOG
@@ -40,6 +49,17 @@ class LiveTrader(Monitor):
self._day_spent_krw: float = 0.0 self._day_spent_krw: float = 0.0
self._day_trades: int = 0 self._day_trades: int = 0
self._day_pnl_krw: float = 0.0 self._day_pnl_krw: float = 0.0
self._gt_trades: list[dict] = []
self._large_legs: set[int] = set()
self._approved_rules: set[str] = set()
self._load_sizing_context()
def _load_sizing_context(self) -> None:
"""GT leg·EV/WF 통과 규칙 캐시."""
gt = load_ground_truth(resolve_ground_truth_file()) or {}
self._gt_trades = gt.get("trades") or []
self._large_legs = top_leg_ids_by_forward_return(self._gt_trades)
self._approved_rules = load_ev_wf_approved_rule_ids()
def _reset_day_if_needed(self) -> None: def _reset_day_if_needed(self) -> None:
"""날짜 변경 시 일별 한도 카운터 초기화.""" """날짜 변경 시 일별 한도 카운터 초기화."""
@@ -61,7 +81,7 @@ class LiveTrader(Monitor):
with LIVE_TRADES_LOG.open("a", encoding="utf-8") as f: with LIVE_TRADES_LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n") f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _can_trade(self, rule_id: str) -> tuple[bool, str]: def _can_trade(self, rule_id: str, planned_krw: float | None = None) -> tuple[bool, str]:
""" """
일·쿨다운·손실 한도 검사. 일·쿨다운·손실 한도 검사.
@@ -74,7 +94,8 @@ class LiveTrader(Monitor):
self._reset_day_if_needed() self._reset_day_if_needed()
if self._day_trades >= LIVE_MAX_TRADES_PER_DAY: if self._day_trades >= LIVE_MAX_TRADES_PER_DAY:
return False, "일 최대 거래 수 초과" return False, "일 최대 거래 수 초과"
if self._day_spent_krw + LIVE_ORDER_KRW > LIVE_DAILY_KRW_MAX: need = float(planned_krw if planned_krw is not None else LIVE_ORDER_KRW)
if self._day_spent_krw + need > LIVE_DAILY_KRW_MAX:
return False, "일 주문 한도 초과" return False, "일 주문 한도 초과"
if self._day_pnl_krw <= -abs(LIVE_DAILY_LOSS_LIMIT_KRW): if self._day_pnl_krw <= -abs(LIVE_DAILY_LOSS_LIMIT_KRW):
return False, "일 손실 한도 초과" return False, "일 손실 한도 초과"
@@ -83,6 +104,46 @@ class LiveTrader(Monitor):
return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)" return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)"
return True, "" return True, ""
def _resolve_buy_amount_krw(self, hit: dict[str, Any]) -> float:
"""
총자산·현금·EV/WF·leg 티어로 매수 원화 산출.
Args:
hit: evaluate_live_rules 항목.
Returns:
매수 원화.
"""
rid = hit["rule_id"]
if rid not in self._approved_rules:
return 0.0
price = float(hit["close"])
cash = 0.0
qty = 0.0
try:
bal = self.load_balances_dict()
sym = bal.get(SYMBOL, {})
cash = float(sym.get("available_krw") or sym.get("krw") or 0)
qty = float(sym.get("balance") or 0)
except Exception:
return 0.0
scale = live_buy_asset_pct_scale(
rid,
hit["dt"],
self._gt_trades,
approved_rules=self._approved_rules,
large_legs=self._large_legs,
)
return compute_buy_amount_krw(
cash,
qty,
price,
1.0,
1.0,
asset_pct_scale=scale,
fee_rate=TRADING_FEE_RATE,
)
def _execute_order(self, hit: dict[str, Any]) -> dict[str, Any]: def _execute_order(self, hit: dict[str, Any]) -> dict[str, Any]:
""" """
매수·매도 주문 실행 또는 드라이런. 매수·매도 주문 실행 또는 드라이런.
@@ -95,6 +156,21 @@ class LiveTrader(Monitor):
""" """
side = hit["side"] side = hit["side"]
price = float(hit["close"]) price = float(hit["close"])
if side == "buy":
amount_krw = self._resolve_buy_amount_krw(hit)
if amount_krw <= 0:
return {
"ts": datetime.now().isoformat(timespec="seconds"),
"rule_id": hit["rule_id"],
"side": side,
"signal_dt": hit["dt"],
"price": price,
"amount_krw": 0,
"live_enabled": LIVE_TRADING_ENABLED,
"ok": False,
"message": "매수 스킵(EV/WF·leg·현금)",
}
else:
amount_krw = float(LIVE_ORDER_KRW) amount_krw = float(LIVE_ORDER_KRW)
record: dict[str, Any] = { record: dict[str, Any] = {
"ts": datetime.now().isoformat(timespec="seconds"), "ts": datetime.now().isoformat(timespec="seconds"),
@@ -163,11 +239,23 @@ class LiveTrader(Monitor):
for hit in fired: for hit in fired:
rid = hit["rule_id"] rid = hit["rule_id"]
ok, reason = self._can_trade(rid) if hit["side"] == "buy" and hit["rule_id"] not in self._approved_rules:
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
print(" skip: EV/WF 미통과 규칙")
continue
planned = (
self._resolve_buy_amount_krw(hit)
if hit["side"] == "buy"
else float(LIVE_ORDER_KRW)
)
ok, reason = self._can_trade(rid, planned)
print(f" [{hit['side']}] {rid} @ {hit['dt']}") print(f" [{hit['side']}] {rid} @ {hit['dt']}")
if not ok: if not ok:
print(f" skip: {reason}") print(f" skip: {reason}")
continue continue
if hit["side"] == "buy" and planned <= 0:
print(" skip: 매수금액 0")
continue
log = self._execute_order(hit) log = self._execute_order(hit)
self._append_log(log) self._append_log(log)
print(f" order: {log['message']} ok={log['ok']}") print(f" order: {log['message']} ok={log['ok']}")

View File

@@ -28,6 +28,8 @@ from config import (
GT_INITIAL_CASH_KRW, GT_INITIAL_CASH_KRW,
GT_MARKER_SIZE_MAX, GT_MARKER_SIZE_MAX,
GT_MARKER_SIZE_MIN, GT_MARKER_SIZE_MIN,
GT_MAX_BUY_ORDER_KRW,
LIVE_ORDER_KRW,
MACD_FAST, MACD_FAST,
MACD_SIGNAL, MACD_SIGNAL,
MACD_SLOW, MACD_SLOW,
@@ -58,16 +60,94 @@ def interval_chart_label(interval_min: int) -> str:
return f"{interval_min}분봉" return f"{interval_min}분봉"
def _marker_sizes(trades: list[dict], action: str) -> list[float]: def _marker_hover_text(
"""비중(weight, 0~1)에 비례한 삼각형 크기.""" label: str,
pts = [t for t in trades if t.get("action") == action] t: dict,
*,
default_order_krw: float | None = None,
extra_lines: list[str] | None = None,
) -> str:
"""
차트 마커 툴팁: 체결가(price)와 체결 원화(amount_krw)를 함께 표시.
Args:
label: 정답/시뮬 매수·매도 라벨.
t: trade dict (price, amount_krw, weight, memo …).
default_order_krw: amount_krw 없을 때 표시할 기본 원화(시뮬 고정 주문).
extra_lines: 툴팁 하단 추가 줄.
Returns:
hovertext HTML 줄바꿈 문자열.
"""
action = t.get("action", t.get("side", ""))
amt_label = "매수금액" if action == "buy" else "매도금액"
lines = [
label,
str(t.get("dt", ""))[:16],
f"체결가 ₩{float(t['price']):,.0f}",
]
ak = t.get("amount_krw")
if ak is not None and float(ak) > 0:
lines.append(f"{amt_label}{float(ak):,.0f}")
elif default_order_krw is not None and action == "buy":
lines.append(f"{amt_label}{float(default_order_krw):,.0f}")
else:
lines.append(f"{amt_label} (미배분)")
if t.get("weight") is not None:
lines.append(f"비중 {float(t.get('weight', 1)) * 100:.0f}%")
if extra_lines:
lines.extend(extra_lines)
memo = t.get("memo", "")
if memo:
lines.append(str(memo))
rule_id = t.get("rule_id", "")
if rule_id:
lines.append(str(rule_id))
return "<br>".join(lines)
def _trade_amount_krw(t: dict) -> float:
"""
마커 크기·툴팁용 체결 원화. amount_krw 없으면 비중×상한으로 추정.
Args:
t: trade dict.
Returns:
원화 금액(0 이상).
"""
ak = t.get("amount_krw")
if ak is not None and float(ak) > 0:
return float(ak)
return max(float(t.get("weight", 1.0)), 0.05) * float(GT_MAX_BUY_ORDER_KRW)
def _marker_sizes(pts: list[dict]) -> list[float]:
"""
체결 원화(amount_krw)에 비례한 삼각형 크기.
같은 trace(매수 또는 매도) 안에서 최소·최대 금액으로 선형 스케일.
Args:
pts: 동일 action의 trade dict 리스트.
Returns:
plotly marker size(diameter) 리스트.
"""
if not pts: if not pts:
return [] return []
lo, hi = float(GT_MARKER_SIZE_MIN), float(GT_MARKER_SIZE_MAX) lo, hi = float(GT_MARKER_SIZE_MIN), float(GT_MARKER_SIZE_MAX)
return [ amounts = [_trade_amount_krw(t) for t in pts]
lo + (hi - lo) * min(max(float(t.get("weight", 1.0)), 0.05), 1.0) amin, amax = min(amounts), max(amounts)
for t in pts sizes: list[float] = []
] for amount in amounts:
if amax > amin:
ratio = (amount - amin) / (amax - amin)
else:
ratio = 0.5
ratio = max(ratio, 0.08)
sizes.append(lo + (hi - lo) * ratio)
return sizes
def _add_sim_markers(fig, trades: list[dict], row: int = 1) -> None: def _add_sim_markers(fig, trades: list[dict], row: int = 1) -> None:
@@ -101,9 +181,14 @@ def _add_sim_markers(fig, trades: list[dict], row: int = 1) -> None:
opacity=0.75, opacity=0.75,
), ),
hovertext=[ hovertext=[
f"{label}<br>{t['dt'][:16]}<br>₩{float(t['price']):,.0f}" _marker_hover_text(
f"<br>leg_gt {float(t.get('forward_ret_pct', 0)):+.2f}%" label,
f"<br>{t.get('rule_id', '')}" t,
default_order_krw=LIVE_ORDER_KRW,
extra_lines=[
f"leg_gt {float(t.get('forward_ret_pct', 0)):+.2f}%",
],
)
for t in pts for t in pts
], ],
hovertemplate="%{hovertext}<extra></extra>", hovertemplate="%{hovertext}<extra></extra>",
@@ -114,7 +199,7 @@ def _add_sim_markers(fig, trades: list[dict], row: int = 1) -> None:
def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None: def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
"""정답 매수·매도 마커 (삼각형 크기 = 비중).""" """정답 매수·매도 마커 (삼각형 크기 = 체결 원화 금액)."""
for action, color, symbol, label in [ for action, color, symbol, label in [
("buy", "#16a34a", "triangle-up", "정답 매수"), ("buy", "#16a34a", "triangle-up", "정답 매수"),
("sell", "#dc2626", "triangle-down", "정답 매도"), ("sell", "#dc2626", "triangle-down", "정답 매도"),
@@ -122,7 +207,7 @@ def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
pts = [t for t in trades if t.get("action") == action] pts = [t for t in trades if t.get("action") == action]
if not pts: if not pts:
continue continue
sizes = _marker_sizes(trades, action) sizes = _marker_sizes(pts)
fig.add_trace( fig.add_trace(
go.Scatter( go.Scatter(
x=[pd.Timestamp(t["dt"]) for t in pts], x=[pd.Timestamp(t["dt"]) for t in pts],
@@ -138,9 +223,7 @@ def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
line=dict(width=1.5, color="#111"), line=dict(width=1.5, color="#111"),
), ),
hovertext=[ hovertext=[
f"{label}<br>{t['dt'][:16]}<br>₩{t['price']:,.0f}" _marker_hover_text(label, t)
f"<br>비중 {float(t.get('weight', 1))*100:.0f}%"
f"<br>{t.get('memo', '')}"
for t in pts for t in pts
], ],
hovertemplate="%{hovertext}<extra></extra>", hovertemplate="%{hovertext}<extra></extra>",
@@ -446,7 +529,7 @@ def build_chart_html(
) )
trade_table = f""" trade_table = f"""
<h2>정답 타점 (ground_truth)</h2> <h2>정답 타점 (ground_truth)</h2>
<p class="meta">삼각형 크기 = 비중. 매수: 저점 분할 / 매도: 고점 1~2회. <p class="meta">삼각형 크기 = 체결 금액(매수/매도 각각 min~max). 매수: 저점 분할 / 매도: 고점 1~2회.
총평가 = 체결 직후 현금 + 보유×체결가.{mark_note}</p> 총평가 = 체결 직후 현금 + 보유×체결가.{mark_note}</p>
<table> <table>
<thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead> <thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead>
@@ -471,8 +554,11 @@ def build_chart_html(
) )
default_legend = ( default_legend = (
"▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 삼각형 크기 = 비중.<br>" "▲ <b>정답 매수</b> · ▼ <b>정답 매도</b> — 크기=체결금액. "
"● <b>시뮬 매수</b> · ● <b>시뮬 매도</b> — 원 = monitor_rules holdout 발화." "매수=총자산×비중×leg티어(상위 대형). "
"툴팁: 체결가·매수/매도금액.<br>"
"● <b>시뮬 매수</b> · ● <b>시뮬 매도</b> — 원 = holdout 발화 "
f"(매수 ₩{LIVE_ORDER_KRW:,.0f}/회)."
) )
if cards_html: if cards_html:
cards_inner = cards_html cards_inner = cards_html

View File

@@ -1,35 +1,78 @@
# 정답 타점 (Ground Truth) # 정답 타점 (Ground Truth)
1(기본 45일) 3분봉 구간에서 **사후 최적 스윙** 매수·매도 라벨을 만듭니다. 1(기본 `CHART_LOOKBACK_DAYS=365`) 3분봉에서 **사후 최적 스윙** 매수·매도 라벨.
실시간 매매 전략이 아니라, 이후 전략 검증·학습용 **정답 데이터**입니다. 실시간 전략이 아니라 규칙·시뮬 검증용 **벤치마크**입니다.
## Plan JSON 필드 `model`에 타점·비중·자본 배분 규칙이 일반화되어 있습니다 (`deepcoin/ground_truth/gt_model.py`).
- **목적**: 차트 상 의미 있는 저점 매수·고점 매도를 JSON으로 고정 ## Plan — 타점 구조 (일반화)
- **방법**: 고점(major swing)에서 1~2회 매도 · 저점(ZigZag+BB)에서 분할 매수 · 삼각형 크기=비중
- **체결 순서**: JSON 저장·포트폴리오 시뮬은 **leg별 매수 전량 → 매도 전량** (시각순 아님). 차트 표는 시각순 정렬.
- **HTML 카드**: 초기 금액, 총보유자산, 초기 대비 증감율(종가 평가 포함). 기간말 leg는 **종가 청산** 포함.
## Do ### Leg (라운드트립 구간)
```bash - **leg_id**: 이전 **고점 매도** 시각 ~ 다음 **고점 매도** 직전까지.
python scripts/02_ground_truth.py # ground_truth_trades.json - 마지막 구간: 마지막 major peak 이후 ~ 기간 말 → **기간말 leg** (종가 청산 1회).
python scripts/05_chart_truth.py # JSON + HTML 차트
### 매수 타점 (Entry)
| 항목 | 규칙 |
|------|------|
| 피벗 | ZigZag **저점(trough)**, `GT_BUY_MIN_SWING_PCT` |
| 가격 | 해당 봉 **Low** |
| 후보 | leg 구간 내 trough, `GT_BUY_MIN_BARS` 간격, BB (`bb_pos <= GT_BUY_BB_MAX`) |
| **비중 weight** | `w_i = (1/price_i) / Σ(1/price_j)`**저가일수록 큰 비중** |
| leg당 상한 | `GT_MAX_BUYS_PER_LEG` (초과 시 저가 순 유지) |
### 매도 타점 (Exit)
| 항목 | 규칙 |
|------|------|
| 피벗 | **major swing 고점(peak)** |
| 가격 | 해당 봉 **High** |
| **비중 weight** | 1회 매도: **100%** · 2회 분할: **65% + 35%** (`GT_SELL_SPLIT_GAP_PCT`) |
| 수량 | leg 보유 수량 × 매도 비중 (마지막 매도 = leg 전량) |
## Do — 자본 배분 (amount_krw)
시각순 체결. **매도 후 현금**이 다음 매수에 반영됩니다.
```
총보유자산 = 현금 + 보유×체결가
최적매수율 = (이번 weight / leg 남은 weight 합) × leg티어스케일
목표매수액 = 총보유자산 × 최적매수율
실제매수액 = min(목표, 가용현금/(1+수수료)), 최소 GT_MIN_ORDER_KRW
``` ```
## Check | leg 티어 | 조건 | 스케일 (`.env`) |
|----------|------|-----------------|
| 대형 | leg 수익률 상위 `GT_LARGE_LEG_TOP_PCT` | `GT_BUY_PCT_LARGE_LEG` (기본 1.0) |
| 소형 | 그 외 | `GT_BUY_PCT_SMALL_LEG` (기본 0.05) |
| 환경 변수 | 기본 | 설명 | **summary.pnl_pct**: 위 배분으로 **시각순** 시뮬 + 기간말 **종가 평가**.
|-----------|------|------|
| `CHART_LOOKBACK_DAYS` | 365 | 조회 일수 (`.env` 기본 1년) | **JSON 저장 순서**: leg별 매수 전량 → 매도 전량 (`leg_block`, 차트·테이블 정합).
| `GT_MIN_SWING_PCT` | 4.0 | ZigZag 최소 스윙(%) |
| `GT_PIVOT_ORDER` | 20 | 국소 극값 반경(봉) | ## 실행
| `GT_MIN_BARS_BETWEEN` | 30 | 체결 간격(3분봉 30봉=90분) |
| `GT_MIN_LEG_PCT` | 8.0 | 한 구간 최소 수익(%) | ```bash
| `GT_MAX_ROUND_TRIPS` | 24 | 최대 라운드트립 | python scripts/02_ground_truth.py # ground_truth_trades.json (+ model)
| `GT_SELECTION_MODE` | split_buy_peak_sell | `split_buy_peak_sell` 등 (`.env` 참고) | python scripts/05_chart_truth.py # HTML 차트
```
## Check — 주요 환경 변수
| 변수 | 기본 | 설명 |
|------|------|------|
| `GT_MIN_SWING_PCT` | 4.0 | 매도 피벗 ZigZag(%) |
| `GT_BUY_MIN_SWING_PCT` | 3.0 | 매수 피벗 ZigZag(%) |
| `GT_PIVOT_ORDER` | 20 | 국소 극값 반경 |
| `GT_MIN_BARS_BETWEEN` | 30 | 체결 최소 간격(봉) |
| `GT_MIN_LEG_PCT` | 8.0 | major leg 최소 수익(%) |
| `GT_BUY_PCT_LARGE_LEG` | 1.0 | 상위 leg 총자산 배분 스케일 |
| `GT_BUY_PCT_SMALL_LEG` | 0.05 | 소형 leg 스케일 |
| `GT_LARGE_LEG_TOP_PCT` | 0.2 | 대형 leg 상위 비율 |
| `GT_MIN_ORDER_KRW` | 5000 | 최소 체결 원화 |
## Act ## Act
- JSON 수동 수정 후 `scripts/05_chart_truth.py` 재실행으로 차트 갱신 - JSON·`model` 수정 후 `02` / `05` 재실행
- 파라미터 조정으로 타점 수·크기 튜닝 - 시뮬 비교: `04_simulation_report.py` (GT vs 시뮬·총자산% vs 고정 ₩/회)

View File

@@ -2,12 +2,12 @@
## 목적 ## 목적
실거래(3단계) 전에 `monitor_rules`**과적합이 아닌지** 숫자로 검증합니다. `monitor_rules`가 과적합이 아닌지 검증하고, **Ground Truth와 동일한 자본 배분 원칙**으로 holdout 체결 수익을 비교합니다.
## 실행 ## 실행
```bash ```bash
python scripts/04_match_rules.py # 선행: 04 전체 또는 select python scripts/04_match_rules.py # 선행
python scripts/04_simulation_report.py python scripts/04_simulation_report.py
``` ```
@@ -15,26 +15,40 @@ python scripts/04_simulation_report.py
| 파일 | 내용 | | 파일 | 내용 |
|------|------| |------|------|
| `docs/04_matching/simulation_report.json` | walk-forward·민감도·Go/No-Go | | `docs/04_matching/simulation_report.json` | Go/No-Go, `portfolio_compare`, `gt_model` |
| `docs/04_matching/simulation_report.html` | GT 동일 카드(초기 금액·총보유자산·증감율)·차트·타점·규칙 기준 | | `docs/04_matching/simulation_report.html` | 카드 3줄: **GT · 시뮬(총자산%) · 시뮬(고정₩/회)** |
## 포트폴리오 비교 (`portfolio_compare`)
| 키 | 설명 |
|----|------|
| `ground_truth_chrono` | GT 타점 + `amount_krw` 시각순 체결 |
| `sim_sized` | holdout 발화 + **총자산×비중×EV/WF·leg상위** (`position_sizing`) |
| `sim_fixed_order` | 동일 발화 + **고정 `LIVE_ORDER_KRW`/회** (baseline) |
## 시뮬 매수 배분 (GT와 동일 원칙)
- **통과 규칙만** 대형: holdout EV·PF, walk-forward, 수수료 2× 스트레스 (`load_ev_wf_approved_rule_ids`)
- **leg 상위** `GT_LARGE_LEG_TOP_PCT` + 근접 GT leg 매칭 → `LIVE_BUY_PCT_LARGE`
- 그 외 → `LIVE_BUY_PCT_SMALL`
- 일한도·일최대거래: `select_capped_fires` (동적 planned 원화로 `LIVE_DAILY_KRW_MAX` 적용)
## 검증 항목 ## 검증 항목
| 항목 | 설명 | | 항목 | 설명 |
|------|------| |------|------|
| Holdout | 최근 15% 구간 EV≥0, PF≥1 | | Holdout | EV≥0, PF≥1 |
| Walk-forward | 월별 EV, 양수 월 비율 ≥ `SIM_GO_WF_POSITIVE_RATIO` | | Walk-forward | 양수 월 비율 ≥ `SIM_GO_WF_POSITIVE_RATIO` |
| 수수료 스트레스 | 수수료 2배(`SIM_FEE_STRESS_MULT`) 후에도 EV≥0 | | 수수료 스트레스 | 수수료 2× EV≥0 |
| 실거래 한도 가정 | `LIVE_ORDER_KRW`·`LIVE_DAILY_KRW_MAX` 내 체결 가능 비율 | | 실거래 한도 | 동적 매수액 기준 일한도 시뮬 |
## Go/No-Go ## Go/No-Go
- **GO**: `monitor_rules` 전 규칙 checks 통과 → 2·3단계 진행 가능 - **GO**: monitor_rules 전 규칙 checks 통과
- **NO-GO**: 04 재선별·규칙 축소 후 재실행 - **NO-GO**: 04 재선별 후 재실행
## 환경 변수 (`config.py` / `.env`) ## 환경 변수
- `SIM_GO_MIN_HOLDOUT_EV`, `SIM_GO_MIN_HOLDOUT_PF` - `SIM_GO_*`, `SIM_WALK_FORWARD_MIN_MONTHS`, `SIM_FEE_STRESS_MULT`
- `SIM_GO_WF_POSITIVE_RATIO` (기본 0.5) - `LIVE_ORDER_KRW`, `LIVE_DAILY_KRW_MAX` (고정 baseline·한도)
- `SIM_WALK_FORWARD_MIN_MONTHS` (기본 3) - `LIVE_BUY_PCT_LARGE`, `LIVE_BUY_PCT_SMALL` (시뮬·실거래 비율 매수)
- `SIM_FEE_STRESS_MULT` (기본 2.0)