40만 원 기준 시뮬·dry-run 정합 및 hybrid 체결 엔진 통합.

초기 자금 GT_INITIAL_CASH_KRW=400000과 원화 한도 비율(알림·LIVE_ORDER·일한도·손실한도)을 맞추고, dry-run/live 체결을 sim_causal_hybrid(replay)와 동일 경로로 통합한다. 시뮬 리포트 갱신, Phase C 슈퍼바이저·매수매도 리허설 스크립트를 추가한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-06-03 11:31:24 +09:00
parent b9ee241d14
commit c6888c9228
24 changed files with 7707 additions and 6253 deletions

View File

@@ -14,7 +14,8 @@ DOWNLOAD_MONTHS=12
# 05/06 루프마다 API 봉을 coins.db에 증분 저장 (01과 동일 append_data) # 05/06 루프마다 API 봉을 coins.db에 증분 저장 (01과 동일 append_data)
MONITOR_PERSIST_CANDLES=1 MONITOR_PERSIST_CANDLES=1
# 02 Ground Truth # 02 Ground Truth · 시뮬·dry-run·live 배분 공통 초기 자금
GT_INITIAL_CASH_KRW=400000
GT_MIN_ORDER_KRW=5000 GT_MIN_ORDER_KRW=5000
GT_BUY_PCT_LARGE_LEG=1.0 GT_BUY_PCT_LARGE_LEG=1.0
GT_BUY_PCT_SMALL_LEG=0.05 GT_BUY_PCT_SMALL_LEG=0.05
@@ -53,20 +54,22 @@ SIM_GO_WF_POSITIVE_RATIO=0.5
SIM_FEE_STRESS_MULT=2.0 SIM_FEE_STRESS_MULT=2.0
# 05 알림 (Phase C: MONITOR_LOOP_SLEEP_SEC=180 권장) # 05 알림 (Phase C: MONITOR_LOOP_SLEEP_SEC=180 권장)
MONITOR_ALERT_COOLDOWN_MIN=180 # 쿨다운 = 최소 봉 간격(3분)과 동일. 루프 주기 MONITOR_LOOP_SLEEP_SEC=180
MONITOR_ALERT_KRW_AMOUNT=100000 MONITOR_ALERT_COOLDOWN_MIN=3
MONITOR_ALERT_KRW_AMOUNT=40000
MONITOR_LOOP_SLEEP_SEC=180 MONITOR_LOOP_SLEEP_SEC=180
MATCH_LIVE_CACHE_SEC=180
# 3 실거래 — Phase별 권장값: docs/05_ops/env.recommended.md # 3 실거래 — Phase별 권장값: docs/05_ops/env.recommended.md
# Phase C (알림만): LIVE_TRADING_ENABLED=0 # Phase C (dry-run): LIVE=0, LIVE_* 무제한(시뮬 정합), COOLDOWN=3(1봉)
# Phase B-1 (소액 live): LIVE_TRADING_ENABLED=1, LIVE_DAILY_KRW_MAX=1000000 # Phase B-1: LIVE=1, LIVE_DAILY_KRW_MAX=400000, MAX_TRADES=15, COOLDOWN=3
# Phase B-2 (sim 근접): LIVE_DAILY_KRW_MAX=5000000 # Phase B-2: LIVE_DAILY_KRW_MAX=5000000, MAX_TRADES=30, COOLDOWN=120
LIVE_TRADING_ENABLED=0 LIVE_TRADING_ENABLED=0
LIVE_ORDER_KRW=100000 LIVE_ORDER_KRW=40000
LIVE_BUY_PCT_LARGE=1.0 LIVE_BUY_PCT_LARGE=1.0
LIVE_BUY_PCT_SMALL=0.05 LIVE_BUY_PCT_SMALL=0.05
LIVE_DAILY_KRW_MAX=300000 LIVE_DAILY_KRW_MAX=4000000
LIVE_COOLDOWN_MIN=180 LIVE_COOLDOWN_MIN=3
LIVE_MAX_TRADES_PER_DAY=10 LIVE_MAX_TRADES_PER_DAY=999
LIVE_DAILY_LOSS_LIMIT_KRW=50000 LIVE_DAILY_LOSS_LIMIT_KRW=20000
LIVE_SLIPPAGE_PCT=0.05 LIVE_SLIPPAGE_PCT=0.05

View File

@@ -208,7 +208,7 @@ GT_SELL_SPLIT_WEIGHTS: tuple[float, ...] = tuple(
GT_BUY_WEIGHT_RULE = _getenv("GT_BUY_WEIGHT_RULE", "inverse_price_normalized") GT_BUY_WEIGHT_RULE = _getenv("GT_BUY_WEIGHT_RULE", "inverse_price_normalized")
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", "400000")
GT_MIN_ORDER_KRW = _getenv_int("GT_MIN_ORDER_KRW", "5000") GT_MIN_ORDER_KRW = _getenv_int("GT_MIN_ORDER_KRW", "5000")
GT_BUY_PCT_LARGE_LEG = _getenv_float("GT_BUY_PCT_LARGE_LEG", "1.0") 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_BUY_PCT_SMALL_LEG = _getenv_float("GT_BUY_PCT_SMALL_LEG", "0.05")
@@ -242,7 +242,7 @@ SIM_TIER_CONVICTION_DD_PCT = _getenv_float("SIM_TIER_CONVICTION_DD_PCT", "10.0")
TRADING_FEE_RATE = _getenv_float("TRADING_FEE_RATE", "0.0005") TRADING_FEE_RATE = _getenv_float("TRADING_FEE_RATE", "0.0005")
# --- 모니터 / API 수집 --- # --- 모니터 / API 수집 ---
MONITOR_LOOP_SLEEP_SEC = _getenv_int("MONITOR_LOOP_SLEEP_SEC", "10") MONITOR_LOOP_SLEEP_SEC = _getenv_int("MONITOR_LOOP_SLEEP_SEC", "180")
MONITOR_POOL_WORKERS = _getenv_int("MONITOR_POOL_WORKERS", "12") MONITOR_POOL_WORKERS = _getenv_int("MONITOR_POOL_WORKERS", "12")
MONITOR_DEFAULT_INTERVAL = _getenv_int("MONITOR_DEFAULT_INTERVAL", "60") MONITOR_DEFAULT_INTERVAL = _getenv_int("MONITOR_DEFAULT_INTERVAL", "60")
MONITOR_API_RETRIES = _getenv_int("MONITOR_API_RETRIES", "3") MONITOR_API_RETRIES = _getenv_int("MONITOR_API_RETRIES", "3")
@@ -257,7 +257,7 @@ MONITOR_MA_WINDOWS: tuple[int, ...] = _parse_int_tuple(
MONITOR_NORM_WINDOW = _getenv_int("MONITOR_NORM_WINDOW", "20") MONITOR_NORM_WINDOW = _getenv_int("MONITOR_NORM_WINDOW", "20")
MONITOR_TELEGRAM_BATCH_SIZE = _getenv_int("MONITOR_TELEGRAM_BATCH_SIZE", "20") MONITOR_TELEGRAM_BATCH_SIZE = _getenv_int("MONITOR_TELEGRAM_BATCH_SIZE", "20")
# 규칙 알림 참고 금액(매수 시 수량=금액/가격). 매도 시에는 보유 수량 우선. # 규칙 알림 참고 금액(매수 시 수량=금액/가격). 매도 시에는 보유 수량 우선.
MONITOR_ALERT_KRW_AMOUNT = _getenv_int("MONITOR_ALERT_KRW_AMOUNT", "100000") MONITOR_ALERT_KRW_AMOUNT = _getenv_int("MONITOR_ALERT_KRW_AMOUNT", "40000")
# 05/06·live_eval API 수집 시 coins.db 증분 INSERT (01_download와 동일 append_data) # 05/06·live_eval API 수집 시 coins.db 증분 INSERT (01_download와 동일 append_data)
MONITOR_PERSIST_CANDLES = _getenv("MONITOR_PERSIST_CANDLES", "1").strip().lower() in ( MONITOR_PERSIST_CANDLES = _getenv("MONITOR_PERSIST_CANDLES", "1").strip().lower() in (
"1", "1",
@@ -341,7 +341,7 @@ MATCH_INCLUDE_MTF_CROSS = _getenv("MATCH_INCLUDE_MTF_CROSS", "1").strip() in (
"yes", "yes",
) )
MATCH_LIVE_LOOKBACK_DAYS = _getenv_int("MATCH_LIVE_LOOKBACK_DAYS", "14") MATCH_LIVE_LOOKBACK_DAYS = _getenv_int("MATCH_LIVE_LOOKBACK_DAYS", "14")
MATCH_LIVE_CACHE_SEC = _getenv_int("MATCH_LIVE_CACHE_SEC", "300") MATCH_LIVE_CACHE_SEC = _getenv_int("MATCH_LIVE_CACHE_SEC", "180")
MATCH_LABEL_MODE = _getenv("MATCH_LABEL_MODE", "leg_gt") MATCH_LABEL_MODE = _getenv("MATCH_LABEL_MODE", "leg_gt")
MATCH_MAX_HOLD_DAYS = _getenv_int("MATCH_MAX_HOLD_DAYS", "45") MATCH_MAX_HOLD_DAYS = _getenv_int("MATCH_MAX_HOLD_DAYS", "45")
MATCH_INCLUDE_ATOMIC = _getenv("MATCH_INCLUDE_ATOMIC", "0").strip() in ( MATCH_INCLUDE_ATOMIC = _getenv("MATCH_INCLUDE_ATOMIC", "0").strip() in (
@@ -353,7 +353,7 @@ MATCH_INCLUDE_ATOMIC = _getenv("MATCH_INCLUDE_ATOMIC", "0").strip() in (
MATCH_HOLDOUT_RATIO = _getenv_float("MATCH_HOLDOUT_RATIO", "0.15") MATCH_HOLDOUT_RATIO = _getenv_float("MATCH_HOLDOUT_RATIO", "0.15")
MATCH_MIN_FIRES_HOLDOUT = _getenv_int("MATCH_MIN_FIRES_HOLDOUT", "5") MATCH_MIN_FIRES_HOLDOUT = _getenv_int("MATCH_MIN_FIRES_HOLDOUT", "5")
MATCH_MONITOR_MAX_PER_SIDE = _getenv_int("MATCH_MONITOR_MAX_PER_SIDE", "1") MATCH_MONITOR_MAX_PER_SIDE = _getenv_int("MATCH_MONITOR_MAX_PER_SIDE", "1")
MONITOR_ALERT_COOLDOWN_MIN = _getenv_int("MONITOR_ALERT_COOLDOWN_MIN", "180") MONITOR_ALERT_COOLDOWN_MIN = _getenv_int("MONITOR_ALERT_COOLDOWN_MIN", "3")
MATCH_KIND_PRIORITY: tuple[str, ...] = tuple( MATCH_KIND_PRIORITY: tuple[str, ...] = tuple(
x.strip() x.strip()
for x in _getenv( for x in _getenv(
@@ -388,11 +388,12 @@ LIVE_TRADING_ENABLED = _getenv("LIVE_TRADING_ENABLED", "0").strip() in (
"True", "True",
"yes", "yes",
) )
LIVE_ORDER_KRW = _getenv_int("LIVE_ORDER_KRW", "100000") LIVE_ORDER_KRW = _getenv_int("LIVE_ORDER_KRW", "40000")
LIVE_BUY_PCT_LARGE = _getenv_float("LIVE_BUY_PCT_LARGE", "1.0") 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_BUY_PCT_SMALL = _getenv_float("LIVE_BUY_PCT_SMALL", "0.05")
LIVE_DAILY_KRW_MAX = _getenv_int("LIVE_DAILY_KRW_MAX", "300000") # Phase C dry-run: 초기자금×10 (hybrid full tier 여유). B-1은 .env에서 400000 권장.
LIVE_COOLDOWN_MIN = _getenv_int("LIVE_COOLDOWN_MIN", "180") LIVE_DAILY_KRW_MAX = _getenv_int("LIVE_DAILY_KRW_MAX", "4000000")
LIVE_COOLDOWN_MIN = _getenv_int("LIVE_COOLDOWN_MIN", "3")
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")
LIVE_DAILY_LOSS_LIMIT_KRW = _getenv_int("LIVE_DAILY_LOSS_LIMIT_KRW", "50000") LIVE_DAILY_LOSS_LIMIT_KRW = _getenv_int("LIVE_DAILY_LOSS_LIMIT_KRW", "20000")
LIVE_SLIPPAGE_PCT = _getenv_float("LIVE_SLIPPAGE_PCT", "0.05") LIVE_SLIPPAGE_PCT = _getenv_float("LIVE_SLIPPAGE_PCT", "0.05")

View File

@@ -0,0 +1,7 @@
{
"current_leg_id": 0,
"open_buys": [],
"completed_leg_ret": {},
"leg_cost_krw": 0.0,
"leg_proceeds_krw": 0.0
}

View File

@@ -1185,7 +1185,7 @@ def simulate_truth_portfolio(
Args: Args:
trades: JSON trades 또는 TradePoint 리스트. trades: JSON trades 또는 TradePoint 리스트.
initial_cash: 시작 원화 (기본 100만). initial_cash: 시작 원화 (기본 GT_INITIAL_CASH_KRW, 40만).
fee_rate: 매수·매도 각각 적용 수수료율. fee_rate: 매수·매도 각각 적용 수수료율.
last_price: 미청산 평가용 종가. None이면 마지막 체결가. last_price: 미청산 평가용 종가. None이면 마지막 체결가.

View File

@@ -497,7 +497,7 @@ def build_simulation_report(
use_amount_krw=True, use_amount_krw=True,
) )
# 전기간 monitor 규칙 — 100만원에서 복리 (holdout만 X) # 전기간 monitor 규칙 — GT_INITIAL_CASH_KRW에서 복리 (holdout만 X)
all_monitor = outcomes[outcomes["rule_id"].isin(monitor_ids)] all_monitor = outcomes[outcomes["rule_id"].isin(monitor_ids)]
if not all_monitor.empty: if not all_monitor.empty:
sim_trades_full = fires_to_trade_list(sort_fires_chronological(all_monitor)) sim_trades_full = fires_to_trade_list(sort_fires_chronological(all_monitor))

View File

@@ -47,16 +47,21 @@ def _holding_qty(balances: dict[str, dict[str, float]], symbol: str) -> float:
def build_rule_alert_message( def build_rule_alert_message(
hit: dict[str, Any], hit: dict[str, Any],
balances: dict[str, dict[str, float]] | None = None, balances: dict[str, dict[str, float]] | None = None,
*,
trade_krw: float | None = None,
trade_qty: float | None = None,
) -> str: ) -> str:
""" """
규칙 발화 알림 본문을 만듭니다. 규칙 발화 알림 본문을 만듭니다.
매수: MONITOR_ALERT_KRW_AMOUNT 기준 수량·금액. trade_krw·trade_qty가 있으면 실제(모의) 체결 규모를 표시합니다.
매도: 보유 수량(잔고 조회 가능 시) × 가격 = 금액, 없으면 참고 금액 기준. 없으면 매수는 MONITOR_ALERT_KRW_AMOUNT, 매도는 보유×가격(또는 참고 금액).
Args: Args:
hit: evaluate_live_rules 항목 (side, rule_id, dt, close). hit: evaluate_live_rules 항목 (side, rule_id, dt, close).
balances: 빗썸 잔고 dict. None이면 매도도 참고 금액 기준. balances: 잔고 dict (모의·실거래).
trade_krw: 체결 원화(모의·실거래 planned/executed).
trade_qty: 체결 수량(매도 시).
Returns: Returns:
텔레그램 메시지 문자열. 텔레그램 메시지 문자열.
@@ -67,14 +72,25 @@ def build_rule_alert_message(
dt = hit.get("dt", "") dt = hit.get("dt", "")
qty_basis = "" qty_basis = ""
if side == "SELL" and balances is not None: if trade_krw is not None and trade_krw > 0:
amount = float(trade_krw)
if trade_qty is not None and trade_qty > 0:
qty = float(trade_qty)
qty_basis = "체결 기준"
elif close > 0:
qty = amount / close
qty_basis = "체결 기준(원화→수량)"
else:
qty = 0.0
qty_basis = "체결 기준"
elif side == "SELL" and balances is not None:
qty = _holding_qty(balances, SYMBOL) qty = _holding_qty(balances, SYMBOL)
amount = qty * close amount = qty * close
qty_basis = "보유 기준" qty_basis = "보유 기준(체결 전)"
elif side == "BUY": elif side == "BUY":
amount = float(MONITOR_ALERT_KRW_AMOUNT) amount = float(MONITOR_ALERT_KRW_AMOUNT)
qty = amount / close if close > 0 else 0.0 qty = amount / close if close > 0 else 0.0
qty_basis = "참고 매수 규모" qty_basis = "참고 매수 규모(알림용)"
else: else:
amount = float(MONITOR_ALERT_KRW_AMOUNT) amount = float(MONITOR_ALERT_KRW_AMOUNT)
qty = amount / close if close > 0 else 0.0 qty = amount / close if close > 0 else 0.0

View File

@@ -0,0 +1,255 @@
"""
시뮬 sim_causal_hybrid 와 동일 체결 엔진 (build_monitor_hybrid_sized_trades).
dry-run·live(06) 모두 발화 이력 → hybrid 배분 → amount_krw·수량 적용.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import pandas as pd
from config import GT_INITIAL_CASH_KRW, TRADING_FEE_RATE
from deepcoin.ground_truth.causal_gt_hybrid import build_monitor_hybrid_sized_trades
from deepcoin.ground_truth.hybrid_dd_calibrate import load_hybrid_dd_params
from deepcoin.ops.paper_portfolio import PaperPortfolio
@dataclass
class SimTradeResult:
"""단일 발화에 대한 시뮬 배분·체결 결과."""
hit: dict[str, Any]
amount_krw: float
sell_qty: float
ok: bool
message: str
leg_id: int | None = None
def hit_key(hit: dict[str, Any]) -> tuple[str, str, str]:
"""발화 고유 키 (dt, rule_id, side)."""
return (str(hit["dt"]), str(hit["rule_id"]), str(hit["side"]))
def sort_hits_sim_order(hits: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
시뮬·allocate 순서: 시각순, 동일 시각이면 buy → sell.
Args:
hits: evaluate_live_rules 발화.
Returns:
정렬된 리스트.
"""
side_rank = {"buy": 0, "sell": 1}
def _key(h: dict[str, Any]) -> tuple:
return (str(h["dt"]), side_rank.get(str(h["side"]), 9), str(h["rule_id"]))
return sorted(hits, key=_key)
def _signals_for_hybrid(
signal_history: list[dict[str, Any]],
*,
approved_buy_rules: set[str] | None,
) -> list[dict[str, Any]]:
"""
hybrid 배분용 신호 목록 (EV/WF 미통과 매수 제외).
Args:
signal_history: {dt, rule_id, side, close}.
approved_buy_rules: 허용 매수 rule_id.
Returns:
시뮬 입력 trade dict 리스트.
"""
out: list[dict[str, Any]] = []
for h in sort_hits_sim_order(signal_history):
side = str(h["side"])
rid = str(h["rule_id"])
if side == "buy" and approved_buy_rules is not None and rid not in approved_buy_rules:
continue
out.append(
{
"dt": str(h["dt"]),
"side": side,
"close": float(h["close"]),
"rule_id": rid,
}
)
return out
def size_monitor_signals(
signal_history: list[dict[str, Any]],
ohlc_df: pd.DataFrame,
*,
approved_buy_rules: set[str] | None = None,
) -> list[dict[str, Any]]:
"""
시뮬과 동일 hybrid tier 배분 (amount_krw·weight·leg_id).
Args:
signal_history: 누적 발화.
ohlc_df: 3m OHLC.
approved_buy_rules: 매수 허용 규칙.
Returns:
sized trade dict 리스트 (시각순).
"""
rows = _signals_for_hybrid(signal_history, approved_buy_rules=approved_buy_rules)
if not rows:
return []
fires = pd.DataFrame(rows)
dd = load_hybrid_dd_params()
sized, _stats = build_monitor_hybrid_sized_trades(
fires,
ohlc_df,
enhanced=False,
initial_cash=float(GT_INITIAL_CASH_KRW),
fee_rate=TRADING_FEE_RATE,
dd_large_pct=dd.get("dd_large_pct"),
dd_medium_pct=dd.get("dd_medium_pct"),
)
return sized
def _find_sized_trade(sized: list[dict[str, Any]], hit: dict[str, Any]) -> dict[str, Any] | None:
"""sized 목록에서 발화 1건 조회."""
dt, rid, side = hit_key(hit)
for t in sized:
action = str(t.get("action", t.get("side", "")))
if str(t.get("dt")) == dt and str(t.get("rule_id", "")) == rid and action == side:
return t
return None
def replay_paper_portfolio(
signal_history: list[dict[str, Any]],
ohlc_df: pd.DataFrame,
*,
approved_buy_rules: set[str] | None = None,
) -> tuple[PaperPortfolio, dict[tuple[str, str, str], SimTradeResult]]:
"""
신호 이력 전체를 시뮬 엔진으로 재생 → 모의 계좌(GT_INITIAL_CASH_KRW) 상태.
Args:
signal_history: Phase C 누적 발화.
ohlc_df: 3m OHLC.
approved_buy_rules: EV/WF 통과 매수 규칙.
Returns:
(portfolio, hit_key → SimTradeResult).
"""
sized = size_monitor_signals(
signal_history, ohlc_df, approved_buy_rules=approved_buy_rules
)
paper = PaperPortfolio()
paper.cash_krw = float(GT_INITIAL_CASH_KRW)
paper.qty = 0.0
paper.qty_by_leg = {}
results: dict[tuple[str, str, str], SimTradeResult] = {}
leg_sell_idxs: dict[int, list[int]] = {}
for i, t in enumerate(sized):
lid = int(t.get("leg_id", 0))
if str(t.get("action", t.get("side"))) == "sell":
leg_sell_idxs.setdefault(lid, []).append(i)
sell_leg: int | None = None
sell_base_qty = 0.0
for i, t in enumerate(sized):
side = str(t.get("action", t.get("side", "")))
price = float(t["price"])
dt = str(t["dt"])
rid = str(t.get("rule_id", ""))
leg_id = int(t.get("leg_id", 0))
hit = {"dt": dt, "rule_id": rid, "side": side, "close": price}
key = hit_key(hit)
amount = float(t.get("amount_krw") or 0)
if side == "buy":
if amount <= 0:
results[key] = SimTradeResult(
hit, 0.0, 0.0, False, "시뮬 매수 스킵(현금·tier)"
)
continue
ok = paper.apply_buy(amount, price, leg_id)
msg = f"paper_buy sim leg={leg_id}{amount:,.0f}" if ok else "paper_buy 실패"
results[key] = SimTradeResult(
hit, amount, 0.0, ok, msg, leg_id=leg_id
)
sell_leg = None
continue
leg_qty = paper.qty_by_leg.get(leg_id, 0.0)
if leg_qty <= 1e-12:
results[key] = SimTradeResult(hit, 0.0, 0.0, False, "모의 보유 없음")
continue
if amount <= 0:
results[key] = SimTradeResult(hit, 0.0, 0.0, False, "시뮬 매도 스킵")
continue
if sell_leg != leg_id:
sell_leg = leg_id
sell_base_qty = leg_qty
rem = [j for j in leg_sell_idxs.get(leg_id, []) if j >= i]
is_last = bool(rem) and i == rem[-1]
sell_qty = leg_qty if is_last else amount / price if price > 0 else 0.0
ok = paper.apply_sell(amount, sell_qty, price, leg_id)
msg = f"paper_sell sim qty={sell_qty:.4f}{amount:,.0f}" if ok else "paper_sell 실패"
results[key] = SimTradeResult(
hit, amount, sell_qty, ok, msg, leg_id=leg_id
)
return paper, results
def plan_live_hit(
signal_history: list[dict[str, Any]],
hit: dict[str, Any],
ohlc_df: pd.DataFrame,
*,
approved_buy_rules: set[str] | None = None,
) -> SimTradeResult:
"""
live: 누적 이력 + 신규 발화 1건 — replay 와 동일 sell_qty·amount.
Args:
signal_history: 기존 이력(신규 hit 미포함).
hit: 이번 발화.
ohlc_df: 3m OHLC.
approved_buy_rules: 매수 허용.
Returns:
SimTradeResult (dry-run replay_paper_portfolio 와 동일).
"""
if ohlc_df is None or getattr(ohlc_df, "empty", True):
return SimTradeResult(hit, 0.0, 0.0, False, "OHLC 없음")
dt, rid, side = hit_key(hit)
hist = list(signal_history)
if not any(
str(s["dt"]) == dt and str(s["rule_id"]) == rid and str(s["side"]) == side
for s in hist
):
hist.append(
{
"dt": dt,
"rule_id": rid,
"side": side,
"close": float(hit["close"]),
}
)
_, results = replay_paper_portfolio(
hist, ohlc_df, approved_buy_rules=approved_buy_rules
)
res = results.get((dt, rid, side))
if res is not None:
return res
return SimTradeResult(hit, 0.0, 0.0, False, "시뮬 배분 없음")

View File

@@ -1,5 +1,7 @@
""" """
3단계: monitor_rules 발화 시 빗썸 실주문 (가드·로그). 3단계: monitor_rules 발화 시 빗썸 실주문 (가드·로그).
dry-run·live 체결 배분: 시뮬 sim_causal_hybrid 와 동일 (hybrid_sim_execution).
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,6 +15,7 @@ from typing import Any
from config import ( from config import (
CHART_LOOKBACK_DAYS, CHART_LOOKBACK_DAYS,
COIN_NAME, COIN_NAME,
GT_INITIAL_CASH_KRW,
LIVE_COOLDOWN_MIN, LIVE_COOLDOWN_MIN,
LIVE_DAILY_KRW_MAX, LIVE_DAILY_KRW_MAX,
LIVE_DAILY_LOSS_LIMIT_KRW, LIVE_DAILY_LOSS_LIMIT_KRW,
@@ -29,20 +32,29 @@ from deepcoin.matching.live_eval import evaluate_live_rules
from deepcoin.matching.live_sizing import LivePositionState, live_sizing_enabled from deepcoin.matching.live_sizing import LivePositionState, live_sizing_enabled
from deepcoin.matching.load_rules import load_monitor_rules from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.matching.position_sizing import ( from deepcoin.matching.position_sizing import (
compute_buy_amount_krw,
live_buy_asset_pct_scale,
load_ev_wf_approved_rule_ids, load_ev_wf_approved_rule_ids,
top_leg_ids_by_forward_return, 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.hybrid_sim_execution import (
hit_key,
plan_live_hit,
replay_paper_portfolio,
sort_hits_sim_order,
)
from deepcoin.ops.monitor import Monitor from deepcoin.ops.monitor import Monitor
from deepcoin.paths import LIVE_TRADES_LOG, PAPER_FIRES_LOG from deepcoin.ops.paper_portfolio import PaperPortfolio
from deepcoin.paths import (
LIVE_SIGNAL_HISTORY_JSON,
LIVE_TRADES_LOG,
PAPER_FIRES_LOG,
resolve_ground_truth_file,
)
class LiveTrader(Monitor): class LiveTrader(Monitor):
""" """
규칙 발화 시 실거래 실행. LIVE_TRADING_ENABLED=0 이면 주문 없음(드라이런 로그만). 규칙 발화 시 실거래 실행. LIVE_TRADING_ENABLED=0 이면 모의(sim hybrid)만.
""" """
def __init__(self) -> None: def __init__(self) -> None:
@@ -57,8 +69,73 @@ class LiveTrader(Monitor):
self._large_legs: set[int] = set() self._large_legs: set[int] = set()
self._approved_rules: set[str] = set() self._approved_rules: set[str] = set()
self._position_state = LivePositionState.load() self._position_state = LivePositionState.load()
self._paper = PaperPortfolio.load() if not LIVE_TRADING_ENABLED else None
self._live_signal_history: list[dict[str, Any]] = []
self._ohlc_df = None self._ohlc_df = None
self._load_sizing_context() self._load_sizing_context()
if self._paper_mode and self._paper.signal_history:
self._resync_paper_from_sim()
if LIVE_TRADING_ENABLED:
self._live_signal_history = self._load_live_signal_history()
@property
def _paper_mode(self) -> bool:
"""dry-run: 모의 계좌·시뮬 hybrid 체결."""
return not LIVE_TRADING_ENABLED and self._paper is not None
def _load_live_signal_history(self) -> list[dict[str, Any]]:
"""live 시뮬 정합용 발화 이력."""
if not LIVE_SIGNAL_HISTORY_JSON.is_file():
return []
try:
data = json.loads(LIVE_SIGNAL_HISTORY_JSON.read_text(encoding="utf-8"))
return list(data.get("signals") or [])
except (json.JSONDecodeError, OSError):
return []
def _save_live_signal_history(self) -> None:
"""live 발화 이력 저장."""
LIVE_SIGNAL_HISTORY_JSON.parent.mkdir(parents=True, exist_ok=True)
LIVE_SIGNAL_HISTORY_JSON.write_text(
json.dumps(
{"signals": self._live_signal_history[-2000:]},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
def _live_signal_seen(self, hit: dict[str, Any]) -> bool:
"""live 이력에 동일 봉 발화가 있는지."""
dt, rid, side = hit_key(hit)
return any(
str(s["dt"]) == dt and str(s["rule_id"]) == rid and str(s["side"]) == side
for s in self._live_signal_history
)
def _append_live_signal(self, hit: dict[str, Any]) -> None:
"""live 발화 이력 추가."""
if self._live_signal_seen(hit):
return
self._live_signal_history.append(
{
"dt": str(hit["dt"]),
"rule_id": str(hit["rule_id"]),
"side": str(hit["side"]),
"close": float(hit["close"]),
}
)
def _balances_for_trading(self) -> dict[str, dict[str, float]] | None:
"""
dry-run: paper_portfolio만. live: 빗썸 API.
"""
if self._paper_mode:
return self._paper.balances_dict()
try:
return self.load_balances_dict()
except Exception:
return None
def _load_sizing_context(self) -> None: def _load_sizing_context(self) -> None:
"""GT leg·EV/WF 통과 규칙 캐시.""" """GT leg·EV/WF 통과 규칙 캐시."""
@@ -77,12 +154,7 @@ class LiveTrader(Monitor):
self._day_pnl_krw = 0.0 self._day_pnl_krw = 0.0
def _append_log(self, record: dict[str, Any]) -> None: def _append_log(self, record: dict[str, Any]) -> None:
""" """live_trades.jsonl append."""
live_trades.jsonl에 한 줄 append.
Args:
record: 로그 dict.
"""
LIVE_TRADES_LOG.parent.mkdir(parents=True, exist_ok=True) LIVE_TRADES_LOG.parent.mkdir(parents=True, exist_ok=True)
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")
@@ -95,11 +167,7 @@ class LiveTrader(Monitor):
skip_reason: str = "", skip_reason: str = "",
order_log: dict[str, Any] | None = None, order_log: dict[str, Any] | None = None,
) -> None: ) -> None:
""" """Phase C paper_fires.jsonl."""
Phase C dry-run: 모든 발화·스킵 사유·모의 금액을 paper_fires.jsonl에 기록.
금요일 `07_phase_c_paper_report.py`로 forward 수익률(참고) 집계.
"""
PAPER_FIRES_LOG.parent.mkdir(parents=True, exist_ok=True) PAPER_FIRES_LOG.parent.mkdir(parents=True, exist_ok=True)
row = { row = {
"ts": datetime.now().isoformat(timespec="seconds"), "ts": datetime.now().isoformat(timespec="seconds"),
@@ -112,21 +180,21 @@ class LiveTrader(Monitor):
"skip_reason": skip_reason or "", "skip_reason": skip_reason or "",
"live_enabled": bool(LIVE_TRADING_ENABLED), "live_enabled": bool(LIVE_TRADING_ENABLED),
"order_message": (order_log or {}).get("message", ""), "order_message": (order_log or {}).get("message", ""),
"sizing": "sim_causal_hybrid",
} }
with PAPER_FIRES_LOG.open("a", encoding="utf-8") as f: with PAPER_FIRES_LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n") f.write(json.dumps(row, ensure_ascii=False) + "\n")
def _can_trade(self, rule_id: str, planned_krw: float | None = None) -> tuple[bool, str]: def _can_trade(self, rule_id: str, planned_krw: float | None = None) -> tuple[bool, str]:
""" """
일·쿨다운·손실 한도 검사. 쿨다운(1봉=3분) + live 일한도. dry-run은 일한도만 생략.
Args:
rule_id: 규칙 ID.
Returns:
(허용 여부, 사유).
""" """
self._reset_day_if_needed() self._reset_day_if_needed()
last = self._rule_last_unix.get(rule_id, 0.0)
if time.time() - last < LIVE_COOLDOWN_MIN * 60:
return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)"
if self._paper_mode:
return True, ""
if self._day_trades >= LIVE_MAX_TRADES_PER_DAY: if self._day_trades >= LIVE_MAX_TRADES_PER_DAY:
return False, "일 최대 거래 수 초과" return False, "일 최대 거래 수 초과"
need = float(planned_krw if planned_krw is not None else LIVE_ORDER_KRW) need = float(planned_krw if planned_krw is not None else LIVE_ORDER_KRW)
@@ -134,147 +202,103 @@ class LiveTrader(Monitor):
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, "일 손실 한도 초과"
last = self._rule_last_unix.get(rule_id, 0.0)
if time.time() - last < LIVE_COOLDOWN_MIN * 60:
return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)"
return True, "" return True, ""
def _load_ohlc_df(self) -> None: def _load_ohlc_df(self) -> None:
"""drawdown tier용 3m OHLC 캐시.""" """drawdown tier용 3m OHLC."""
try: try:
frames = load_frames_from_db(self, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS) frames = load_frames_from_db(self, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
self._ohlc_df = frames.get(MATCH_PRIMARY_INTERVAL) self._ohlc_df = frames.get(MATCH_PRIMARY_INTERVAL)
except Exception: except Exception:
self._ohlc_df = None self._ohlc_df = None
def _resolve_buy_amount_krw(self, hit: dict[str, Any]) -> float: def _resync_paper_from_sim(self) -> None:
""" """기존 paper 잔고를 sim_causal_hybrid replay 로 맞춤."""
총자산·현금·EV/WF·leg 티어로 매수 원화 산출. if self._ohlc_df is None:
self._load_ohlc_df()
GT_SIGNAL_CAUSAL=1 이면 시뮬 sim_primary(hybrid, enhanced=False)와 동일 tier·weight. if self._ohlc_df is None or getattr(self._ohlc_df, "empty", True):
return
Args: replayed, _ = replay_paper_portfolio(
hit: evaluate_live_rules 항목. self._paper.signal_history,
self._ohlc_df,
Returns: approved_buy_rules=self._approved_rules,
매수 원화.
"""
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
if live_sizing_enabled():
if self._ohlc_df is None:
self._load_ohlc_df()
return self._position_state.plan_buy_amount_krw(
hit["dt"],
price,
cash,
qty,
self._ohlc_df,
enhanced=False,
fee_rate=TRADING_FEE_RATE,
)
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( self._paper.cash_krw = replayed.cash_krw
cash, self._paper.qty = replayed.qty
qty, self._paper.qty_by_leg = dict(replayed.qty_by_leg)
price, self._paper.current_leg_id = replayed.current_leg_id
1.0, self._paper.save()
1.0,
asset_pct_scale=scale, def _sim_plan(self, hit: dict[str, Any]) -> Any:
fee_rate=TRADING_FEE_RATE, """시뮬 hybrid 배분 1건."""
if self._ohlc_df is None:
self._load_ohlc_df()
if self._paper_mode:
hist = list(self._paper.signal_history)
else:
hist = list(self._live_signal_history)
return plan_live_hit(
hist,
hit,
self._ohlc_df,
approved_buy_rules=self._approved_rules,
) )
def _execute_order(self, hit: dict[str, Any]) -> dict[str, Any]: def _execute_live_order(self, hit: dict[str, Any], plan: Any) -> dict[str, Any]:
""" """실거래: 시뮬 planned 금액·수량으로 API 주문."""
매수·매도 주문 실행 또는 드라이런.
Args:
hit: evaluate_live_rules 항목.
Returns:
로그용 결과 dict.
"""
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)
record: dict[str, Any] = { record: dict[str, Any] = {
"ts": datetime.now().isoformat(timespec="seconds"), "ts": datetime.now().isoformat(timespec="seconds"),
"rule_id": hit["rule_id"], "rule_id": hit["rule_id"],
"side": side, "side": side,
"signal_dt": hit["dt"], "signal_dt": hit["dt"],
"price": price, "price": price,
"amount_krw": amount_krw, "amount_krw": plan.amount_krw,
"live_enabled": LIVE_TRADING_ENABLED, "live_enabled": True,
"ok": False, "ok": False,
"message": "", "message": plan.message,
"sizing": "sim_causal_hybrid",
} }
if not plan.ok:
if not LIVE_TRADING_ENABLED:
record["message"] = "dry_run (LIVE_TRADING_ENABLED=0)"
record["ok"] = True
return record return record
try: try:
if side == "buy": if side == "buy":
ok = self.buyCoinMarket(SYMBOL, int(amount_krw), count=None) ok = self.buyCoinMarket(SYMBOL, int(plan.amount_krw), count=None)
record["ok"] = bool(ok) record["ok"] = bool(ok)
record["message"] = "buyCoinMarket" if ok else "buy failed" record["message"] = "buyCoinMarket" if ok else "buy failed"
elif side == "sell": elif side == "sell":
bal = self.load_balances_dict().get(SYMBOL, {}) bal = self.load_balances_dict().get(SYMBOL, {})
qty = float(bal.get("balance") or 0) held = float(bal.get("balance") or 0)
if qty <= 0: if held <= 0:
record["message"] = "보유 없음" record["message"] = "보유 없음"
else: else:
gross = qty * price sell_qty = min(float(plan.sell_qty), held)
record["amount_krw"] = round(gross, 0) if sell_qty <= 0:
ok = self.sellCoinMarket(SYMBOL, int(price), qty) record["message"] = "매도 수량 0"
record["ok"] = bool(ok) else:
record["message"] = f"sell qty={qty}" if ok else "sell failed" gross = sell_qty * price
if record["ok"] and live_sizing_enabled(): record["amount_krw"] = round(gross, 0)
fee = gross * TRADING_FEE_RATE ok = self.sellCoinMarket(SYMBOL, int(price), sell_qty)
self._position_state.record_sell( record["ok"] = bool(ok)
gross, fee, full_close=True record["sell_qty"] = sell_qty
record["message"] = (
f"sell qty={sell_qty:.4f}" if ok else "sell failed"
) )
self._position_state.save() if record["ok"] and live_sizing_enabled():
fee = gross * TRADING_FEE_RATE
self._position_state.record_sell(
gross, fee, full_close=(sell_qty >= held * 0.999)
)
self._position_state.save()
else: else:
record["message"] = f"unknown side {side}" record["message"] = f"unknown side {side}"
except Exception as exc: except Exception as exc:
record["message"] = str(exc) record["message"] = str(exc)
if record["ok"]: if record["ok"]:
spent = float(record.get("amount_krw") or amount_krw) spent = float(record.get("amount_krw") or plan.amount_krw)
self._day_spent_krw += spent self._day_spent_krw += spent
self._day_trades += 1 self._day_trades += 1
self._rule_last_unix[hit["rule_id"]] = time.time() self._rule_last_unix[hit["rule_id"]] = time.time()
@@ -282,74 +306,158 @@ class LiveTrader(Monitor):
fee = spent * TRADING_FEE_RATE fee = spent * TRADING_FEE_RATE
self._position_state.record_buy(hit["dt"], price, spent, fee) self._position_state.record_buy(hit["dt"], price, spent, fee)
self._position_state.save() self._position_state.save()
self._append_live_signal(hit)
self._save_live_signal_history()
return record return record
def _process_paper_batch(self, new_hits: list[dict[str, Any]]) -> None:
"""
dry-run: 신규 발화를 이력에 넣고 시뮬 전체 재생 후 알림.
"""
if not new_hits:
return
if self._ohlc_df is None:
self._load_ohlc_df()
for hit in new_hits:
self._paper.append_signal(hit)
replayed, results = replay_paper_portfolio(
self._paper.signal_history,
self._ohlc_df,
approved_buy_rules=self._approved_rules,
)
self._paper.cash_krw = replayed.cash_krw
self._paper.qty = replayed.qty
self._paper.qty_by_leg = dict(replayed.qty_by_leg)
self._paper.current_leg_id = replayed.current_leg_id
for hit in new_hits:
key = hit_key(hit)
res = results.get(key)
if res is None:
self._paper.mark_processed(hit["rule_id"], hit["dt"])
continue
log = {
"ok": res.ok,
"message": res.message,
"amount_krw": res.amount_krw,
"sell_qty": res.sell_qty,
}
self._append_paper_fire(
hit, res.amount_krw, res.ok, "" if res.ok else res.message, log
)
self._paper.mark_processed(hit["rule_id"], hit["dt"])
print(f" [{hit['side']}] {hit['rule_id']} @ {hit['dt']}")
print(f" order: {res.message} ok={res.ok}")
if not res.ok:
continue
self._rule_last_unix[hit["rule_id"]] = time.time()
post_balances = self._paper.balances_dict()
msg = build_rule_alert_message(
hit,
post_balances,
trade_krw=res.amount_krw,
trade_qty=res.sell_qty if hit["side"] == "sell" else None,
)
sym = post_balances.get(SYMBOL, {})
msg += (
f"\n[모의잔고·체결후] 현금 {_fmt_paper_krw(sym.get('krw', 0))} · "
f"보유 {float(sym.get('balance', 0)):.4f} {SYMBOL}"
)
msg += f"\n[체결] {res.message}"
self._send_coin_msg(msg)
self._paper.save()
def run_once(self) -> None: def run_once(self) -> None:
"""1회: 규칙 평가 → (허용 시) 주문 → 텔레그램.""" """1회: 규칙 평가 → 시뮬 hybrid 체결 → 텔레그램."""
rules = load_monitor_rules() rules = load_monitor_rules()
print( print(
f"[06] {datetime.now():%Y-%m-%d %H:%M:%S} " f"[06] {datetime.now():%Y-%m-%d %H:%M:%S} "
f"{COIN_NAME} live={'ON' if LIVE_TRADING_ENABLED else 'OFF'} " f"{COIN_NAME} live={'ON' if LIVE_TRADING_ENABLED else 'OFF'} "
f"rules={len(rules)}" f"rules={len(rules)} · sim=hybrid · bar={MATCH_PRIMARY_INTERVAL}m"
) )
if not rules: if not rules:
print(" monitor_rules 없음") print(" monitor_rules 없음")
return return
fired = evaluate_live_rules(rules) fired = evaluate_live_rules(rules, force_refresh=True)
balances = None
try:
balances = self.load_balances_dict()
except Exception:
pass
if not fired: if not fired:
print(" 발화 없음") print(" 발화 없음")
return return
for hit in fired: if self._paper_mode:
print(
f" [paper] 현금 ₩{self._paper.cash_krw:,.0f} · "
f"보유 {self._paper.qty:.4f} {SYMBOL} "
f"(초기 ₩{GT_INITIAL_CASH_KRW:,.0f})"
)
new_paper_hits: list[dict[str, Any]] = []
for hit in sort_hits_sim_order(fired):
rid = hit["rule_id"] rid = hit["rule_id"]
if hit["side"] == "buy" and hit["rule_id"] not in self._approved_rules: if self._paper_mode and self._paper.already_processed(rid, hit["dt"]):
print(f" [{hit['side']}] {rid} @ {hit['dt']} (이미 처리)")
continue
if LIVE_TRADING_ENABLED and self._live_signal_seen(hit):
continue
if hit["side"] == "buy" and rid not in self._approved_rules:
print(f" [{hit['side']}] {rid} @ {hit['dt']}") print(f" [{hit['side']}] {rid} @ {hit['dt']}")
print(" skip: EV/WF 미통과 규칙") print(" skip: EV/WF 미통과 규칙")
self._append_paper_fire( if self._paper_mode:
hit, 0.0, False, "EV/WF 미통과 규칙" self._append_paper_fire(hit, 0.0, False, "EV/WF 미통과 규칙")
) self._paper.mark_processed(rid, hit["dt"])
continue continue
planned = (
self._resolve_buy_amount_krw(hit) plan_preview = self._sim_plan(hit)
if hit["side"] == "buy" ok, reason = self._can_trade(rid, plan_preview.amount_krw)
else float(LIVE_ORDER_KRW)
)
ok, reason = self._can_trade(rid, planned)
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
if not ok: if not ok:
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
print(f" skip: {reason}") print(f" skip: {reason}")
self._append_paper_fire(hit, planned, False, reason) if self._paper_mode:
self._append_paper_fire(
hit, plan_preview.amount_krw, False, reason
)
self._paper.mark_processed(rid, hit["dt"])
continue continue
if hit["side"] == "buy" and planned <= 0:
print(" skip: 매수금액 0") if self._paper_mode:
self._append_paper_fire(hit, 0.0, False, "매수금액 0") new_paper_hits.append(hit)
continue continue
log = self._execute_order(hit)
self._append_paper_fire(hit, planned, True, "", log) print(f" [{hit['side']}] {rid} @ {hit['dt']}")
log = self._execute_live_order(hit, plan_preview)
self._append_log(log) self._append_log(log)
print(f" order: {log['message']} ok={log['ok']}") print(f" order: {log['message']} ok={log['ok']}")
msg = build_rule_alert_message(hit, balances) if not log["ok"]:
if log["ok"]: continue
msg += f"\n[체결] {log['message']}" balances = self._balances_for_trading()
else: msg = build_rule_alert_message(
msg += f"\n[실패] {log['message']}" hit,
balances,
trade_krw=float(log.get("amount_krw") or 0),
trade_qty=float(log.get("sell_qty") or 0) or None,
)
if balances:
sym = balances.get(SYMBOL, {})
msg += (
f"\n[잔고] 현금 {_fmt_paper_krw(sym.get('krw', 0))} · "
f"보유 {float(sym.get('balance', 0)):.4f} {SYMBOL}"
)
msg += f"\n[체결] {log['message']}"
self._send_coin_msg(msg) self._send_coin_msg(msg)
def run_loop(self, sleep_sec: int) -> None: if self._paper_mode and new_paper_hits:
""" self._process_paper_batch(new_paper_hits)
상시 루프.
Args: def run_loop(self, sleep_sec: int) -> None:
sleep_sec: 대기 초. """상시 루프."""
"""
print(f"[06] 실거래 루프 시작 · sleep={sleep_sec}s") print(f"[06] 실거래 루프 시작 · sleep={sleep_sec}s")
while True: while True:
self.run_once() self.run_once()
time.sleep(sleep_sec) time.sleep(sleep_sec)
def _fmt_paper_krw(value: float) -> str:
"""원화 표시."""
return f"{float(value):,.0f}"

View File

@@ -0,0 +1,337 @@
"""
Phase C dry-run 모의 포트폴리오 — 시뮬 allocate_order_amounts_chronological 와 동일 현금·보유 규칙.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from config import (
GT_INITIAL_CASH_KRW,
GT_MAX_SELLS_PER_LEG,
GT_MIN_ORDER_KRW,
SYMBOL,
TRADING_FEE_RATE,
)
from deepcoin.ground_truth.gt_allocation import resolve_sell_qty
from deepcoin.ground_truth.gt_model import leg_exit_weights
from deepcoin.paths import PAPER_FIRES_LOG, PAPER_PORTFOLIO_JSON
class PaperPortfolio:
"""
dry-run 전용 현금·코인 보유 (초기 GT_INITIAL_CASH_KRW, 실거래 API 미사용).
"""
def __init__(self) -> None:
"""빈 모의 계좌."""
self.cash_krw: float = float(GT_INITIAL_CASH_KRW)
self.qty: float = 0.0
self.qty_by_leg: dict[int, float] = {}
self.current_leg_id: int = 0
self.sell_leg: int | None = None
self.sell_base_qty: float = 0.0
self.sells_done_by_leg: dict[int, int] = {}
self.processed_signals: list[str] = []
self.signal_history: list[dict[str, Any]] = []
@classmethod
def load(cls, path: Path | None = None) -> PaperPortfolio:
"""
디스크에서 복원. 없으면 GT_INITIAL_CASH_KRW(기본 40만 원).
Args:
path: JSON 경로.
Returns:
PaperPortfolio.
"""
p = path or PAPER_PORTFOLIO_JSON
st = cls()
if not p.is_file():
return st
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return st
st.cash_krw = float(data.get("cash_krw", GT_INITIAL_CASH_KRW))
st.qty = float(data.get("qty") or 0.0)
st.qty_by_leg = {int(k): float(v) for k, v in (data.get("qty_by_leg") or {}).items()}
st.current_leg_id = int(data.get("current_leg_id") or 0)
st.sell_leg = data.get("sell_leg")
if st.sell_leg is not None:
st.sell_leg = int(st.sell_leg)
st.sell_base_qty = float(data.get("sell_base_qty") or 0.0)
st.sells_done_by_leg = {
int(k): int(v) for k, v in (data.get("sells_done_by_leg") or {}).items()
}
st.processed_signals = list(data.get("processed_signals") or [])[-500:]
st.signal_history = list(data.get("signal_history") or [])[-2000:]
if not st.signal_history:
st._rebuild_signal_history_from_fires()
return st
def _rebuild_signal_history_from_fires(self) -> None:
"""
구버전 paper(이력 없음) → paper_fires.jsonl 에서 would_trade 복원.
"""
if not PAPER_FIRES_LOG.is_file():
return
seen: set[tuple[str, str, str]] = set()
rows: list[dict[str, Any]] = []
try:
for line in PAPER_FIRES_LOG.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
row = json.loads(line)
if not row.get("would_trade"):
continue
dt = str(row.get("signal_dt") or "")
rid = str(row.get("rule_id") or "")
side = str(row.get("side") or "")
if not dt or not rid or not side:
continue
key = (dt, rid, side)
if key in seen:
continue
seen.add(key)
rows.append(
{
"dt": dt,
"rule_id": rid,
"side": side,
"close": float(row.get("close") or 0),
}
)
except (json.JSONDecodeError, OSError, TypeError, ValueError):
return
self.signal_history = rows[-2000:]
def append_signal(self, hit: dict[str, Any]) -> None:
"""
시뮬 재생용 발화 이력 추가 (dt·rule_id·side·close).
Args:
hit: evaluate_live_rules 항목.
"""
row = {
"dt": str(hit["dt"]),
"rule_id": str(hit["rule_id"]),
"side": str(hit["side"]),
"close": float(hit["close"]),
}
key = self.signal_key(row["rule_id"], row["dt"])
if key in self.processed_signals:
return
if any(
s["dt"] == row["dt"]
and s["rule_id"] == row["rule_id"]
and s["side"] == row["side"]
for s in self.signal_history
):
return
self.signal_history.append(row)
def save(self, path: Path | None = None) -> None:
"""상태 저장."""
p = path or PAPER_PORTFOLIO_JSON
p.parent.mkdir(parents=True, exist_ok=True)
payload = {
"cash_krw": round(self.cash_krw, 0),
"qty": self.qty,
"qty_by_leg": {str(k): round(v, 8) for k, v in self.qty_by_leg.items()},
"current_leg_id": self.current_leg_id,
"sell_leg": self.sell_leg,
"sell_base_qty": round(self.sell_base_qty, 8),
"sells_done_by_leg": self.sells_done_by_leg,
"processed_signals": self.processed_signals[-500:],
"signal_history": self.signal_history[-2000:],
"initial_cash_krw": GT_INITIAL_CASH_KRW,
"sizing_engine": "sim_causal_hybrid",
}
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def balances_dict(self) -> dict[str, dict[str, float]]:
"""live_trader·alert용 잔고 dict."""
return {
SYMBOL: {
"balance": self.qty,
"available_krw": self.cash_krw,
"krw": self.cash_krw,
}
}
def signal_key(self, rule_id: str, signal_dt: str) -> str:
"""동일 봉 중복 발화 방지 키."""
return f"{rule_id}|{signal_dt}"
def already_processed(self, rule_id: str, signal_dt: str) -> bool:
"""이미 체결·스킵 처리한 신호인지."""
return self.signal_key(rule_id, signal_dt) in self.processed_signals
def mark_processed(self, rule_id: str, signal_dt: str) -> None:
"""신호 처리 완료 표시."""
key = self.signal_key(rule_id, signal_dt)
if key not in self.processed_signals:
self.processed_signals.append(key)
def active_leg_id(self) -> int | None:
"""보유 수량이 있는 leg_id (없으면 None)."""
for lid, q in sorted(self.qty_by_leg.items()):
if q > 1e-12:
return lid
return None
def apply_buy(self, amount_krw: float, price: float, leg_id: int) -> bool:
"""
모의 매수 체결.
Args:
amount_krw: 매수 원화.
price: 체결가.
leg_id: leg ID.
Returns:
체결 성공 여부.
"""
if amount_krw <= 0 or price <= 0:
return False
fee = amount_krw * TRADING_FEE_RATE
if self.cash_krw < amount_krw + fee:
return False
self.cash_krw -= amount_krw + fee
bought = amount_krw / price
self.qty += bought
self.qty_by_leg[leg_id] = self.qty_by_leg.get(leg_id, 0.0) + bought
self.current_leg_id = leg_id
self.sell_leg = None
self.sell_base_qty = 0.0
return True
def plan_sell(
self,
price: float,
leg_id: int | None = None,
) -> tuple[float, float, str]:
"""
분할 매도 규모 (시뮬 leg_exit_weights·GT_MAX_SELLS_PER_LEG).
Args:
price: 체결가.
leg_id: 대상 leg. None이면 active_leg_id.
Returns:
(amount_krw, sell_qty, skip_reason). skip_reason 비어 있으면 체결 가능.
"""
lid = leg_id if leg_id is not None else self.active_leg_id()
if lid is None:
return 0.0, 0.0, "모의 보유 없음"
leg_qty = self.qty_by_leg.get(lid, 0.0)
if leg_qty <= 1e-12:
return 0.0, 0.0, "모의 보유 없음"
if self.sell_leg != lid:
self.sell_leg = lid
self.sell_base_qty = leg_qty
n_sells = max(1, int(GT_MAX_SELLS_PER_LEG))
weights = leg_exit_weights(n_sells)
idx = self.sells_done_by_leg.get(lid, 0)
is_last = idx >= len(weights) - 1
if is_last:
sell_qty = leg_qty
gross = sell_qty * price
else:
weight = float(weights[idx])
trade = {"amount_krw": None, "weight": weight}
sell_qty = resolve_sell_qty(
trade, leg_qty, price, self.sell_base_qty, weight
)
gross = sell_qty * price
if gross < GT_MIN_ORDER_KRW and leg_qty * price >= GT_MIN_ORDER_KRW:
gross = GT_MIN_ORDER_KRW
sell_qty = min(leg_qty, gross / price)
if gross <= 0 or sell_qty <= 0:
return 0.0, 0.0, "모의 매도 규모 0"
return round(gross, 0), sell_qty, ""
def apply_sell(
self,
amount_krw: float,
sell_qty: float,
price: float,
leg_id: int,
) -> bool:
"""
모의 매도 체결.
Args:
amount_krw: 매도 원화(총액).
sell_qty: 매도 수량.
price: 체결가.
leg_id: leg ID.
Returns:
체결 성공 여부.
"""
if sell_qty <= 0 or amount_krw <= 0:
return False
fee = amount_krw * TRADING_FEE_RATE
self.cash_krw += amount_krw - fee
leg_qty = self.qty_by_leg.get(leg_id, 0.0) - sell_qty
self.qty_by_leg[leg_id] = max(leg_qty, 0.0)
self.qty = max(self.qty - sell_qty, 0.0)
if self.qty < 1e-12:
self.qty = 0.0
self.sells_done_by_leg[leg_id] = self.sells_done_by_leg.get(leg_id, 0) + 1
if self.qty_by_leg.get(leg_id, 0.0) <= 1e-12:
self.qty_by_leg.pop(leg_id, None)
self.sell_leg = None
self.sell_base_qty = 0.0
self.sells_done_by_leg.pop(leg_id, None)
return True
def equity_krw(self, mark_price: float) -> float:
"""
총보유금액 = 현금 + 코인 평가(시세).
Args:
mark_price: 평가 단가.
Returns:
원화 합계.
"""
return float(self.cash_krw) + float(self.qty) * float(mark_price)
def summary(self, mark_price: float) -> dict[str, Any]:
"""
dry-run 모의 계좌 스냅샷 (빗썸 잔고와 무관).
Args:
mark_price: 최신 종가 등 평가 단가.
Returns:
initial·cash·qty·equity·pnl dict.
"""
initial = float(GT_INITIAL_CASH_KRW)
equity = self.equity_krw(mark_price)
pnl = equity - initial
pnl_pct = (pnl / initial * 100.0) if initial > 0 else 0.0
coin_value = float(self.qty) * float(mark_price)
return {
"initial_cash_krw": round(initial, 0),
"cash_krw": round(self.cash_krw, 0),
"qty": round(self.qty, 8),
"mark_price": round(mark_price, 4),
"coin_value_krw": round(coin_value, 0),
"equity_krw": round(equity, 0),
"pnl_krw": round(pnl, 0),
"pnl_pct": round(pnl_pct, 4),
"source": "paper_portfolio.json (dry-run only, not Bithumb)",
}

View File

@@ -54,7 +54,12 @@ MATCHING_GT_COMPARISON_HTML = DOCS_MATCHING / "gt_comparison_report.html"
LIVE_TRADES_LOG = OPS_STATE_DIR / "live_trades.jsonl" LIVE_TRADES_LOG = OPS_STATE_DIR / "live_trades.jsonl"
PAPER_FIRES_LOG = OPS_STATE_DIR / "paper_fires.jsonl" PAPER_FIRES_LOG = OPS_STATE_DIR / "paper_fires.jsonl"
PAPER_PORTFOLIO_JSON = OPS_STATE_DIR / "paper_portfolio.json"
LIVE_SIGNAL_HISTORY_JSON = OPS_STATE_DIR / "live_signal_history.json"
PAPER_WEEKLY_REPORT_JSON = DOCS_OPS / "phase_c_paper_report.json" PAPER_WEEKLY_REPORT_JSON = DOCS_OPS / "phase_c_paper_report.json"
PHASE_C_DAILY_DIR = DOCS_OPS / "phase_c_daily"
PHASE_C_SUPERVISOR_LOG = OPS_STATE_DIR / "phase_c_supervisor.log"
PHASE_C_SUPERVISOR_PID = OPS_STATE_DIR / "phase_c_supervisor.pid"
CHART_BB_HTML = DOCS_CHARTS / "wld_bb_chart.html" CHART_BB_HTML = DOCS_CHARTS / "wld_bb_chart.html"
CHART_TRUTH_HTML = DOCS_GROUND_TRUTH / "wld_ground_truth_chart.html" CHART_TRUTH_HTML = DOCS_GROUND_TRUTH / "wld_ground_truth_chart.html"

File diff suppressed because one or more lines are too long

View File

@@ -6,75 +6,75 @@
"walk_forward": [ "walk_forward": [
{ {
"month": "2025-06", "month": "2025-06",
"pnl_pct": 12.78, "pnl_pct": 10.02,
"start_asset_krw": 1000000.0, "start_asset_krw": 400000.0,
"end_asset_krw": 1127825.0 "end_asset_krw": 440064.0
}, },
{ {
"month": "2025-07", "month": "2025-07",
"pnl_pct": 60.89, "pnl_pct": 60.89,
"start_asset_krw": 1127825.0, "start_asset_krw": 440064.0,
"end_asset_krw": 1814584.0 "end_asset_krw": 708031.0
}, },
{ {
"month": "2025-08", "month": "2025-08",
"pnl_pct": 22.9, "pnl_pct": 22.9,
"start_asset_krw": 1814584.0, "start_asset_krw": 708031.0,
"end_asset_krw": 2230083.0 "end_asset_krw": 870154.0
}, },
{ {
"month": "2025-09", "month": "2025-09",
"pnl_pct": 57.63, "pnl_pct": 58.24,
"start_asset_krw": 2230083.0, "start_asset_krw": 870154.0,
"end_asset_krw": 3515283.0 "end_asset_krw": 1376957.0
}, },
{ {
"month": "2025-10", "month": "2025-10",
"pnl_pct": 9.29, "pnl_pct": 1.41,
"start_asset_krw": 3515283.0, "start_asset_krw": 1376957.0,
"end_asset_krw": 3842010.0 "end_asset_krw": 1396376.0
}, },
{ {
"month": "2025-11", "month": "2025-11",
"pnl_pct": 11.24, "pnl_pct": 19.55,
"start_asset_krw": 3842010.0, "start_asset_krw": 1396376.0,
"end_asset_krw": 4273771.0 "end_asset_krw": 1669367.0
}, },
{ {
"month": "2025-12", "month": "2025-12",
"pnl_pct": -0.87, "pnl_pct": -1.53,
"start_asset_krw": 4273771.0, "start_asset_krw": 1669367.0,
"end_asset_krw": 4236421.0 "end_asset_krw": 1643749.0
}, },
{ {
"month": "2026-01", "month": "2026-01",
"pnl_pct": 33.77, "pnl_pct": 28.68,
"start_asset_krw": 4236421.0, "start_asset_krw": 1643749.0,
"end_asset_krw": 5666889.0 "end_asset_krw": 2115197.0
}, },
{ {
"month": "2026-02", "month": "2026-02",
"pnl_pct": 15.61, "pnl_pct": 20.21,
"start_asset_krw": 5666889.0, "start_asset_krw": 2115197.0,
"end_asset_krw": 6551242.0 "end_asset_krw": 2542690.0
}, },
{ {
"month": "2026-03", "month": "2026-03",
"pnl_pct": 9.12, "pnl_pct": 9.7,
"start_asset_krw": 6551242.0, "start_asset_krw": 2542690.0,
"end_asset_krw": 7148390.0 "end_asset_krw": 2789334.0
}, },
{ {
"month": "2026-04", "month": "2026-04",
"pnl_pct": 22.85, "pnl_pct": 22.86,
"start_asset_krw": 7148390.0, "start_asset_krw": 2789334.0,
"end_asset_krw": 8782116.0 "end_asset_krw": 3426893.0
}, },
{ {
"month": "2026-05", "month": "2026-05",
"pnl_pct": 47.57, "pnl_pct": 44.66,
"start_asset_krw": 8782116.0, "start_asset_krw": 3426893.0,
"end_asset_krw": 12959660.0 "end_asset_krw": 4957419.0
} }
], ],
"walk_forward_summary": { "walk_forward_summary": {
@@ -292,29 +292,29 @@
{ {
"name": "hybrid_holdout_pnl", "name": "hybrid_holdout_pnl",
"pass": true, "pass": true,
"value": 62.35 "value": 59.15
}, },
{ {
"name": "hybrid_max_mdd", "name": "hybrid_max_mdd",
"pass": true, "pass": true,
"value": 19.22 "value": 19.89
}, },
{ {
"name": "hybrid_fee_stress_pnl", "name": "hybrid_fee_stress_pnl",
"pass": true, "pass": true,
"value": 975.74 "value": 947.42
}, },
{ {
"name": "option_c_target_300pct", "name": "option_c_target_300pct",
"pass": true, "pass": true,
"value": 1147.3, "value": 1116.87,
"optional": true "optional": true
} }
] ]
}, },
"go_no_go_option_c_phase2": { "go_no_go_option_c_phase2": {
"go": true, "go": false,
"gt_capture_ratio": 0.2674, "gt_capture_ratio": 0.1587,
"targets": { "targets": {
"phase2_pnl_pct": 1000.0, "phase2_pnl_pct": 1000.0,
"min_gt_capture": 0.23, "min_gt_capture": 0.23,
@@ -328,33 +328,33 @@
{ {
"name": "full_pnl_1000pct", "name": "full_pnl_1000pct",
"pass": true, "pass": true,
"value": 1147.3 "value": 1116.87
}, },
{ {
"name": "gt_capture_23pct", "name": "gt_capture_23pct",
"pass": true, "pass": false,
"value": 0.2674 "value": 0.1587
}, },
{ {
"name": "holdout_pnl_positive", "name": "holdout_pnl_positive",
"pass": true, "pass": true,
"value": 62.35 "value": 59.15
}, },
{ {
"name": "max_mdd", "name": "max_mdd",
"pass": true, "pass": true,
"value": 19.22 "value": 19.89
}, },
{ {
"name": "fee_stress_ratio", "name": "fee_stress_ratio",
"pass": true, "pass": true,
"value": 975.74, "value": 947.42,
"threshold": 850.0 "threshold": 850.0
}, },
{ {
"name": "slippage_stress_positive", "name": "slippage_stress_positive",
"pass": true, "pass": true,
"value": 31.58, "value": 28.14,
"note": "체결가 슬리피지 반영 후에도 흑자" "note": "체결가 슬리피지 반영 후에도 흑자"
}, },
{ {
@@ -366,87 +366,87 @@
}, },
"portfolio_compare": { "portfolio_compare": {
"ground_truth_chrono": { "ground_truth_chrono": {
"initial_cash_krw": 1000000, "initial_cash_krw": 400000,
"final_asset_krw": 43913514.0, "final_asset_krw": 28554960.0,
"pnl_krw": 42913514.0, "pnl_krw": 28154960.0,
"pnl_pct": 4291.35, "pnl_pct": 7038.74,
"total_fees_krw": 240578.0, "total_fees_krw": 240578.0,
"cash_krw": 43913514.0, "cash_krw": 28554960.0,
"holding_qty": 0.0, "holding_qty": 0.0,
"holding_value_krw": 0.0, "holding_value_krw": 0.0,
"mark_price": 487.0, "mark_price": 487.0,
"fee_rate": 0.0005, "fee_rate": 0.0005,
"trade_count": 456, "trade_count": 456,
"max_drawdown_pct": 6.17, "max_drawdown_pct": 8.34,
"peak_asset_krw": 1234838.0, "peak_asset_krw": 9032973.0,
"trough_asset_krw": 1158619.0 "trough_asset_krw": 8279398.0
}, },
"sim_sized": { "sim_sized": {
"initial_cash_krw": 1000000, "initial_cash_krw": 400000,
"final_asset_krw": 1746868.0, "final_asset_krw": 546248.0,
"pnl_krw": 746868.0, "pnl_krw": 146248.0,
"pnl_pct": 74.69, "pnl_pct": 36.56,
"total_fees_krw": 29780.0, "total_fees_krw": 15161.0,
"cash_krw": 1509979.0, "cash_krw": 467164.0,
"holding_qty": 486.425998, "holding_qty": 162.389581,
"holding_value_krw": 236889.0, "holding_value_krw": 79084.0,
"mark_price": 487.0, "mark_price": 487.0,
"fee_rate": 0.0005, "fee_rate": 0.0005,
"trade_count": 5225, "trade_count": 5225,
"max_drawdown_pct": 9.53, "max_drawdown_pct": 20.54,
"peak_asset_krw": 1023021.0, "peak_asset_krw": 412326.0,
"trough_asset_krw": 925549.0, "trough_asset_krw": 327644.0,
"sizing_mode": "gt_model_compound_causal", "sizing_mode": "gt_model_compound_causal",
"sizing_note": "전기간 복리·GT tier·총자산×비중, 보유현금 한도; 인과적 신호·tier(미래 미사용)" "sizing_note": "전기간 복리·GT tier·총자산×비중, 보유현금 한도; 인과적 신호·tier(미래 미사용)"
}, },
"sim_fixed_order": { "sim_fixed_order": {
"initial_cash_krw": 1000000, "initial_cash_krw": 400000,
"final_asset_krw": 58389.0, "final_asset_krw": 23356.0,
"pnl_krw": -941611.0, "pnl_krw": -376644.0,
"pnl_pct": -94.16, "pnl_pct": -94.16,
"total_fees_krw": 43869.0, "total_fees_krw": 17548.0,
"cash_krw": -0.0, "cash_krw": 0.0,
"holding_qty": 119.896264, "holding_qty": 47.958506,
"holding_value_krw": 58389.0, "holding_value_krw": 23356.0,
"mark_price": 487.0, "mark_price": 487.0,
"fee_rate": 0.0005, "fee_rate": 0.0005,
"order_krw": 100000.0, "order_krw": 40000.0,
"sizing_mode": "fixed", "sizing_mode": "fixed",
"trade_count": 5225 "trade_count": 5225
}, },
"sim_gt_model": { "sim_gt_model": {
"initial_cash_krw": 1000000, "initial_cash_krw": 400000,
"final_asset_krw": 1081984.0, "final_asset_krw": 418244.0,
"pnl_krw": 81984.0, "pnl_krw": 18244.0,
"pnl_pct": 8.2, "pnl_pct": 4.56,
"total_fees_krw": 240.0, "total_fees_krw": 120.0,
"cash_krw": 881042.0, "cash_krw": 309487.0,
"holding_qty": 412.612808, "holding_qty": 223.320164,
"holding_value_krw": 200942.0, "holding_value_krw": 108757.0,
"mark_price": 487.0, "mark_price": 487.0,
"fee_rate": 0.0005, "fee_rate": 0.0005,
"trade_count": 33, "trade_count": 33,
"max_drawdown_pct": 4.38, "max_drawdown_pct": 9.79,
"peak_asset_krw": 1072413.0, "peak_asset_krw": 429734.0,
"trough_asset_krw": 1025457.0, "trough_asset_krw": 387649.0,
"sizing_mode": "gt_model_compound_causal", "sizing_mode": "gt_model_compound_causal",
"sizing_note": "전기간 복리·GT tier·총자산×비중, 보유현금 한도; 인과적 신호·tier(미래 미사용)" "sizing_note": "전기간 복리·GT tier·총자산×비중, 보유현금 한도; 인과적 신호·tier(미래 미사용)"
}, },
"sim_causal_gt": { "sim_causal_gt": {
"initial_cash_krw": 1000000, "initial_cash_krw": 400000,
"final_asset_krw": 1147944.0, "final_asset_krw": 459948.0,
"pnl_krw": 147944.0, "pnl_krw": 59948.0,
"pnl_pct": 14.79, "pnl_pct": 14.99,
"total_fees_krw": 2025.0, "total_fees_krw": 900.0,
"cash_krw": 1147944.0, "cash_krw": 459948.0,
"holding_qty": 0.0, "holding_qty": 0.0,
"holding_value_krw": 0.0, "holding_value_krw": 0.0,
"mark_price": 487.0, "mark_price": 487.0,
"fee_rate": 0.0005, "fee_rate": 0.0005,
"trade_count": 124, "trade_count": 129,
"max_drawdown_pct": 0.96, "max_drawdown_pct": 1.51,
"peak_asset_krw": 1118535.0, "peak_asset_krw": 448991.0,
"trough_asset_krw": 1107793.0, "trough_asset_krw": 442216.0,
"leg_count": 17, "leg_count": 17,
"sizing_mode": "causal_gt_leg_engine", "sizing_mode": "causal_gt_leg_engine",
"sizing_note": "인과 GT leg: split_buy + peak_sell, causal tier 복리 (미래 미사용)", "sizing_note": "인과 GT leg: split_buy + peak_sell, causal tier 복리 (미래 미사용)",
@@ -461,46 +461,46 @@
"use_local_trough": false "use_local_trough": false
}, },
"alloc_stats": { "alloc_stats": {
"buy_executed": 91, "buy_executed": 96,
"buy_skipped": 0, "buy_skipped": 0,
"sell_executed": 33, "sell_executed": 33,
"sell_skipped": 0, "sell_skipped": 0,
"buy_total_krw": 1950020.0, "buy_total_krw": 870005.0,
"large_leg_count": 0, "large_leg_count": 0,
"large_tier_buy_count": 0, "large_tier_buy_count": 0,
"buy_amount_avg_krw": 21429.0, "buy_amount_avg_krw": 9063.0,
"buy_amount_min_krw": 5000, "buy_amount_min_krw": 5000,
"buy_amount_max_krw": 57216.0 "buy_amount_max_krw": 22925.0
} }
}, },
"sim_causal_hybrid": { "sim_causal_hybrid": {
"initial_cash_krw": 1000000, "initial_cash_krw": 400000,
"final_asset_krw": 12473032.0, "final_asset_krw": 4867465.0,
"pnl_krw": 11473032.0, "pnl_krw": 4467465.0,
"pnl_pct": 1147.3, "pnl_pct": 1116.87,
"total_fees_krw": 710297.0, "total_fees_krw": 277183.0,
"cash_krw": 0.0, "cash_krw": 3.0,
"holding_qty": 25611.975633, "holding_qty": 9994.789459,
"holding_value_krw": 12473032.0, "holding_value_krw": 4867462.0,
"mark_price": 487.0, "mark_price": 487.0,
"fee_rate": 0.0005, "fee_rate": 0.0005,
"trade_count": 5225, "trade_count": 5225,
"max_drawdown_pct": 19.22, "max_drawdown_pct": 19.89,
"peak_asset_krw": 1174413.0, "peak_asset_krw": 3104956.0,
"trough_asset_krw": 948744.0, "trough_asset_krw": 2487267.0,
"sizing_mode": "monitor_dd_tier", "sizing_mode": "monitor_dd_tier",
"sizing_note": "monitor buy+sell + drawdown·past-leg tier (미래 미사용)", "sizing_note": "monitor buy+sell + drawdown·past-leg tier (미래 미사용)",
"alloc_stats": { "alloc_stats": {
"buy_executed": 1632, "buy_executed": 1628,
"buy_skipped": 655, "buy_skipped": 659,
"sell_executed": 2938, "sell_executed": 2938,
"sell_skipped": 0, "sell_skipped": 0,
"buy_total_krw": 710442254.0, "buy_total_krw": 277244801.0,
"large_leg_count": 1535, "large_leg_count": 1532,
"large_tier_buy_count": 1535, "large_tier_buy_count": 1532,
"buy_amount_avg_krw": 435320.0, "buy_amount_avg_krw": 170298.0,
"buy_amount_min_krw": 828.0, "buy_amount_min_krw": 323.0,
"buy_amount_max_krw": 8620153.0 "buy_amount_max_krw": 3363487.0
}, },
"input_fires": 5225 "input_fires": 5225
}, },
@@ -509,208 +509,208 @@
"dd_medium_pct": 2.0 "dd_medium_pct": 2.0
}, },
"sim_tier_enhanced": { "sim_tier_enhanced": {
"initial_cash_krw": 1000000, "initial_cash_krw": 400000,
"final_asset_krw": 487437.0, "final_asset_krw": 232667.0,
"pnl_krw": -512563.0, "pnl_krw": -167333.0,
"pnl_pct": -51.26, "pnl_pct": -41.83,
"total_fees_krw": 107068.0, "total_fees_krw": 51034.0,
"cash_krw": 1.0, "cash_krw": -0.0,
"holding_qty": 1000.895401, "holding_qty": 477.75476,
"holding_value_krw": 487436.0, "holding_value_krw": 232667.0,
"mark_price": 487.0, "mark_price": 487.0,
"fee_rate": 0.0005, "fee_rate": 0.0005,
"trade_count": 5225, "trade_count": 5225,
"max_drawdown_pct": 74.08, "max_drawdown_pct": 73.4,
"peak_asset_krw": 1540984.0, "peak_asset_krw": 735586.0,
"trough_asset_krw": 399432.0, "trough_asset_krw": 195638.0,
"sizing_mode": "monitor_tier_enhanced", "sizing_mode": "monitor_tier_enhanced",
"sizing_note": "monitor buy+sell + past-leg·drawdown tier + conviction (미래 미사용)", "sizing_note": "monitor buy+sell + past-leg·drawdown tier + conviction (미래 미사용)",
"alloc_stats": { "alloc_stats": {
"buy_executed": 279, "buy_executed": 241,
"buy_skipped": 2008, "buy_skipped": 2046,
"sell_executed": 2938, "sell_executed": 2938,
"sell_skipped": 0, "sell_skipped": 0,
"buy_total_krw": 107514607.0, "buy_total_krw": 51208940.0,
"large_leg_count": 136, "large_leg_count": 143,
"large_tier_buy_count": 136, "large_tier_buy_count": 143,
"buy_amount_avg_krw": 385357.0, "buy_amount_avg_krw": 212485.0,
"buy_amount_min_krw": 5000, "buy_amount_min_krw": 5000,
"buy_amount_max_krw": 1540213.0 "buy_amount_max_krw": 735224.0
}, },
"input_fires": 5225 "input_fires": 5225
}, },
"sim_sized_holdout": { "sim_sized_holdout": {
"initial_asset_krw": 1605322.0, "initial_asset_krw": 515736.0,
"final_asset_krw": 1751246.0, "final_asset_krw": 547709.0,
"pnl_krw": 145924.0, "pnl_krw": 31973.0,
"pnl_pct": 9.09, "pnl_pct": 6.2,
"trade_count": 845, "trade_count": 845,
"note": "전기간 복리(causal tier) 후 holdout 구간 자산 증감" "note": "전기간 복리(causal tier) 후 holdout 구간 자산 증감"
}, },
"sim_hybrid_holdout": { "sim_hybrid_holdout": {
"initial_asset_krw": 7982769.0, "initial_asset_krw": 3114931.0,
"final_asset_krw": 12959660.0, "final_asset_krw": 4957419.0,
"pnl_krw": 4976891.0, "pnl_krw": 1842488.0,
"pnl_pct": 62.35, "pnl_pct": 59.15,
"trade_count": 845, "trade_count": 845,
"note": "전기간 복리(hybrid DD tier) 후 holdout 구간 자산 증감" "note": "전기간 복리(hybrid DD tier) 후 holdout 구간 자산 증감"
}, },
"sim_hybrid_fee_stress": { "sim_hybrid_fee_stress": {
"initial_cash_krw": 1000000, "initial_cash_krw": 400000,
"final_asset_krw": 10757384.0, "final_asset_krw": 4189669.0,
"pnl_krw": 9757384.0, "pnl_krw": 3789669.0,
"pnl_pct": 975.74, "pnl_pct": 947.42,
"total_fees_krw": 1289111.0, "total_fees_krw": 502923.0,
"cash_krw": -0.0, "cash_krw": -0.0,
"holding_qty": 22089.084224, "holding_qty": 8603.015894,
"holding_value_krw": 10757384.0, "holding_value_krw": 4189669.0,
"mark_price": 487.0, "mark_price": 487.0,
"fee_rate": 0.001, "fee_rate": 0.001,
"trade_count": 5225, "trade_count": 5225,
"max_drawdown_pct": 16.02, "max_drawdown_pct": 20.18,
"peak_asset_krw": 7104706.0, "peak_asset_krw": 460724.0,
"trough_asset_krw": 5966238.0 "trough_asset_krw": 367741.0
}, },
"sim_hybrid_slippage_stress": { "sim_hybrid_slippage_stress": {
"initial_cash_krw": 1000000, "initial_cash_krw": 400000,
"final_asset_krw": 1315755.0, "final_asset_krw": 512580.0,
"pnl_krw": 315755.0, "pnl_krw": 112580.0,
"pnl_pct": 31.58, "pnl_pct": 28.14,
"total_fees_krw": 710297.0, "total_fees_krw": 277183.0,
"cash_krw": -0.0, "cash_krw": -0.0,
"holding_qty": 2701.755603, "holding_qty": 1052.52527,
"holding_value_krw": 1315755.0, "holding_value_krw": 512580.0,
"mark_price": 487.0, "mark_price": 487.0,
"fee_rate": 0.0005, "fee_rate": 0.0005,
"trade_count": 5225, "trade_count": 5225,
"max_drawdown_pct": 59.08, "max_drawdown_pct": 59.13,
"peak_asset_krw": 3483599.0, "peak_asset_krw": 1358898.0,
"trough_asset_krw": 1425625.0, "trough_asset_krw": 555385.0,
"slippage_pct": 0.05, "slippage_pct": 0.05,
"sizing_mode": "hybrid_slippage_stress" "sizing_mode": "hybrid_slippage_stress"
}, },
"hybrid_portfolio_walk_forward": [ "hybrid_portfolio_walk_forward": [
{ {
"month": "2025-06", "month": "2025-06",
"pnl_pct": 12.78, "pnl_pct": 10.02,
"start_asset_krw": 1000000.0, "start_asset_krw": 400000.0,
"end_asset_krw": 1127825.0 "end_asset_krw": 440064.0
}, },
{ {
"month": "2025-07", "month": "2025-07",
"pnl_pct": 60.89, "pnl_pct": 60.89,
"start_asset_krw": 1127825.0, "start_asset_krw": 440064.0,
"end_asset_krw": 1814584.0 "end_asset_krw": 708031.0
}, },
{ {
"month": "2025-08", "month": "2025-08",
"pnl_pct": 22.9, "pnl_pct": 22.9,
"start_asset_krw": 1814584.0, "start_asset_krw": 708031.0,
"end_asset_krw": 2230083.0 "end_asset_krw": 870154.0
}, },
{ {
"month": "2025-09", "month": "2025-09",
"pnl_pct": 57.63, "pnl_pct": 58.24,
"start_asset_krw": 2230083.0, "start_asset_krw": 870154.0,
"end_asset_krw": 3515283.0 "end_asset_krw": 1376957.0
}, },
{ {
"month": "2025-10", "month": "2025-10",
"pnl_pct": 9.29, "pnl_pct": 1.41,
"start_asset_krw": 3515283.0, "start_asset_krw": 1376957.0,
"end_asset_krw": 3842010.0 "end_asset_krw": 1396376.0
}, },
{ {
"month": "2025-11", "month": "2025-11",
"pnl_pct": 11.24, "pnl_pct": 19.55,
"start_asset_krw": 3842010.0, "start_asset_krw": 1396376.0,
"end_asset_krw": 4273771.0 "end_asset_krw": 1669367.0
}, },
{ {
"month": "2025-12", "month": "2025-12",
"pnl_pct": -0.87, "pnl_pct": -1.53,
"start_asset_krw": 4273771.0, "start_asset_krw": 1669367.0,
"end_asset_krw": 4236421.0 "end_asset_krw": 1643749.0
}, },
{ {
"month": "2026-01", "month": "2026-01",
"pnl_pct": 33.77, "pnl_pct": 28.68,
"start_asset_krw": 4236421.0, "start_asset_krw": 1643749.0,
"end_asset_krw": 5666889.0 "end_asset_krw": 2115197.0
}, },
{ {
"month": "2026-02", "month": "2026-02",
"pnl_pct": 15.61, "pnl_pct": 20.21,
"start_asset_krw": 5666889.0, "start_asset_krw": 2115197.0,
"end_asset_krw": 6551242.0 "end_asset_krw": 2542690.0
}, },
{ {
"month": "2026-03", "month": "2026-03",
"pnl_pct": 9.12, "pnl_pct": 9.7,
"start_asset_krw": 6551242.0, "start_asset_krw": 2542690.0,
"end_asset_krw": 7148390.0 "end_asset_krw": 2789334.0
}, },
{ {
"month": "2026-04", "month": "2026-04",
"pnl_pct": 22.85, "pnl_pct": 22.86,
"start_asset_krw": 7148390.0, "start_asset_krw": 2789334.0,
"end_asset_krw": 8782116.0 "end_asset_krw": 3426893.0
}, },
{ {
"month": "2026-05", "month": "2026-05",
"pnl_pct": 47.57, "pnl_pct": 44.66,
"start_asset_krw": 8782116.0, "start_asset_krw": 3426893.0,
"end_asset_krw": 12959660.0 "end_asset_krw": 4957419.0
} }
], ],
"hybrid_portfolio_wf_summary": { "hybrid_portfolio_wf_summary": {
"months": 12, "months": 12,
"positive_months": 11, "positive_months": 11,
"positive_ratio": 0.9167, "positive_ratio": 0.9167,
"mean_pnl_pct": 25.23 "mean_pnl_pct": 24.8
}, },
"primary_sizing": "hybrid", "primary_sizing": "hybrid",
"sim_primary": { "sim_primary": {
"initial_cash_krw": 1000000, "initial_cash_krw": 400000,
"final_asset_krw": 12473032.0, "final_asset_krw": 4867465.0,
"pnl_krw": 11473032.0, "pnl_krw": 4467465.0,
"pnl_pct": 1147.3, "pnl_pct": 1116.87,
"total_fees_krw": 710297.0, "total_fees_krw": 277183.0,
"cash_krw": 0.0, "cash_krw": 3.0,
"holding_qty": 25611.975633, "holding_qty": 9994.789459,
"holding_value_krw": 12473032.0, "holding_value_krw": 4867462.0,
"mark_price": 487.0, "mark_price": 487.0,
"fee_rate": 0.0005, "fee_rate": 0.0005,
"trade_count": 5225, "trade_count": 5225,
"max_drawdown_pct": 19.22, "max_drawdown_pct": 19.89,
"peak_asset_krw": 1174413.0, "peak_asset_krw": 3104956.0,
"trough_asset_krw": 948744.0, "trough_asset_krw": 2487267.0,
"sizing_mode": "primary_hybrid_dd_tier", "sizing_mode": "primary_hybrid_dd_tier",
"sizing_note": "권장: monitor + past-leg·drawdown tier (검증 통과, 미래 미사용)", "sizing_note": "권장: monitor + past-leg·drawdown tier (검증 통과, 미래 미사용)",
"alloc_stats": { "alloc_stats": {
"buy_executed": 1632, "buy_executed": 1628,
"buy_skipped": 655, "buy_skipped": 659,
"sell_executed": 2938, "sell_executed": 2938,
"sell_skipped": 0, "sell_skipped": 0,
"buy_total_krw": 710442254.0, "buy_total_krw": 277244801.0,
"large_leg_count": 1535, "large_leg_count": 1532,
"large_tier_buy_count": 1535, "large_tier_buy_count": 1532,
"buy_amount_avg_krw": 435320.0, "buy_amount_avg_krw": 170298.0,
"buy_amount_min_krw": 828.0, "buy_amount_min_krw": 323.0,
"buy_amount_max_krw": 8620153.0 "buy_amount_max_krw": 3363487.0
}, },
"input_fires": 5225 "input_fires": 5225
}, },
"gt_capture_ratio": 0.0174, "gt_capture_ratio": 0.0052,
"gt_pnl_pct": 4291.35, "gt_pnl_pct": 7038.74,
"sim_sized_pnl_pct": 74.69, "sim_sized_pnl_pct": 36.56,
"gt_model_capture_ratio": 0.0019, "gt_model_capture_ratio": 0.0006,
"causal_gt_capture_ratio": 0.0034, "causal_gt_capture_ratio": 0.0021,
"sim_causal_gt_pnl_pct": 14.79, "sim_causal_gt_pnl_pct": 14.99,
"causal_hybrid_capture_ratio": 0.2674, "causal_hybrid_capture_ratio": 0.1587,
"sim_causal_hybrid_pnl_pct": 1147.3, "sim_causal_hybrid_pnl_pct": 1116.87,
"tier_enhanced_capture_ratio": -0.0119, "tier_enhanced_capture_ratio": -0.0059,
"sim_tier_enhanced_pnl_pct": -51.26, "sim_tier_enhanced_pnl_pct": -41.83,
"causal_gt_params": { "causal_gt_params": {
"peak_mode": "local", "peak_mode": "local",
"pivot_order": 8, "pivot_order": 8,
@@ -746,28 +746,28 @@
"observed_implied_scale": { "observed_implied_scale": {
"all": { "all": {
"count": 18, "count": 18,
"mean": 0.1106, "mean": 0.3111,
"median": 0.0527, "median": 0.1346,
"p25": 0.051, "p25": 0.1309,
"p75": 0.054 "p75": 0.1509
}, },
"large_leg": { "large_leg": {
"count": 1, "count": 1,
"mean": 1.0812, "mean": 3.2206,
"median": 1.0812, "median": 3.2206,
"p25": 1.0812, "p25": 3.2206,
"p75": 1.0812 "p75": 3.2206
}, },
"small_leg": { "small_leg": {
"count": 17, "count": 17,
"mean": 0.0535, "mean": 0.14,
"median": 0.051, "median": 0.1323,
"p25": 0.051, "p25": 0.1309,
"p75": 0.0539 "p75": 0.1502
} }
}, },
"recommended_pct_large_leg": 1.0812, "recommended_pct_large_leg": 3.2206,
"recommended_pct_small_leg": 0.051, "recommended_pct_small_leg": 0.1323,
"note": "implied_scale = amount / (pre_buy_total_asset × weight_share); 시뮬 tier는 GT 분석 median 사용" "note": "implied_scale = amount / (pre_buy_total_asset × weight_share); 시뮬 tier는 GT 분석 median 사용"
}, },
"causal_mode": { "causal_mode": {
@@ -1499,15 +1499,15 @@
], ],
"gt_portfolio_calibration": { "gt_portfolio_calibration": {
"portfolio": { "portfolio": {
"gt_final_asset_krw": 43913513.0, "gt_final_asset_krw": 28785435.0,
"subset_final_asset_krw": 1000000.0, "subset_final_asset_krw": 400000.0,
"asset_ratio": 0.0228, "asset_ratio": 0.0139,
"asset_accuracy_pct": 2.28, "asset_accuracy_pct": 1.39,
"target_met_90": false, "target_met_90": false,
"legs_total": 76, "legs_total": 76,
"legs_covered": 0, "legs_covered": 0,
"leg_coverage_ratio": 0.0, "leg_coverage_ratio": 0.0,
"full_pnl_pct": 4291.35, "full_pnl_pct": 7096.36,
"subset_pnl_pct": 0.0 "subset_pnl_pct": 0.0
}, },
"note": "캘리브레이션 미실행 — scripts/04_calibrate_gt_assets.py" "note": "캘리브레이션 미실행 — scripts/04_calibrate_gt_assets.py"

View File

@@ -1,6 +1,6 @@
# DeepCoin 배포 체크리스트 (C → B) # DeepCoin 배포 체크리스트 (C → B)
- **목표:** 초기 ₩1,000,000 → **+1,000% 이상** (인과적 hybrid primary 경로) - **목표:** 초기 ₩400,000 (`GT_INITIAL_CASH_KRW`)**+1,000% 이상** (인과적 hybrid primary 경로)
- **일정:** **금요일까지 Phase C** (알림·dry-run 사전 테스트) → **토요일부터 Phase B-1** (소액 실거래) - **일정:** **금요일까지 Phase C** (알림·dry-run 사전 테스트) → **토요일부터 Phase B-1** (소액 실거래)
- **기준일:** 2026-06-01 (월) 작성 · C 종료 2026-06-05 (금) · B-1 시작 2026-06-06 (토) - **기준일:** 2026-06-01 (월) 작성 · C 종료 2026-06-05 (금) · B-1 시작 2026-06-06 (토)
@@ -111,12 +111,15 @@ Phase B-2 (검증 후) sim에 근접한 한도 · +1000% 경로 추격 (리스
`.env`에 아래를 적용하세요. 전체 예시는 `docs/05_ops/env.recommended.md` 참고. `.env`에 아래를 적용하세요. 전체 예시는 `docs/05_ops/env.recommended.md` 참고.
```env ```env
# === Phase C: 알림만 (금~금) === # === Phase C: dry-run (금~금, 시뮬 정합) ===
LIVE_TRADING_ENABLED=0 LIVE_TRADING_ENABLED=0
GT_SIGNAL_CAUSAL=1 GT_SIGNAL_CAUSAL=1
SIM_PRIMARY_SIZING=auto SIM_PRIMARY_SIZING=auto
MONITOR_ALERT_COOLDOWN_MIN=180 MONITOR_ALERT_COOLDOWN_MIN=3
MONITOR_LOOP_SLEEP_SEC=180 MONITOR_LOOP_SLEEP_SEC=180
LIVE_DAILY_KRW_MAX=4000000
LIVE_MAX_TRADES_PER_DAY=999
LIVE_COOLDOWN_MIN=3
``` ```
### 4.2 매일 실행 ### 4.2 매일 실행
@@ -171,17 +174,17 @@ GT_SIGNAL_CAUSAL=1
SIM_PRIMARY_SIZING=auto SIM_PRIMARY_SIZING=auto
# hybrid tier: large 구간 일부 체결 가능, sim보다 보수적 # hybrid tier: large 구간 일부 체결 가능, sim보다 보수적
LIVE_ORDER_KRW=100000 LIVE_ORDER_KRW=40000
LIVE_DAILY_KRW_MAX=1000000 LIVE_DAILY_KRW_MAX=400000
LIVE_MAX_TRADES_PER_DAY=15 LIVE_MAX_TRADES_PER_DAY=15
LIVE_COOLDOWN_MIN=180 LIVE_COOLDOWN_MIN=3
LIVE_DAILY_LOSS_LIMIT_KRW=100000 LIVE_DAILY_LOSS_LIMIT_KRW=40000
LIVE_SLIPPAGE_PCT=0.05 LIVE_SLIPPAGE_PCT=0.05
``` ```
| 변수 | B-1 값 | 의미 | | 변수 | B-1 값 | 의미 |
|------|--------|------| |------|--------|------|
| `LIVE_DAILY_KRW_MAX` | **1,000,000** | 일 100만 (sim full tier보다 낮음) | | `LIVE_DAILY_KRW_MAX` | **400,000** | 일 40만 (초기 1배, sim full tier보다 낮음) |
| `LIVE_DAILY_LOSS_LIMIT_KRW` | **100,000** | 일 -10만 시 당일 중단 | | `LIVE_DAILY_LOSS_LIMIT_KRW` | **100,000** | 일 -10만 시 당일 중단 |
| `LIVE_MAX_TRADES_PER_DAY` | 15 | sim 거래 빈도 대비 여유 | | `LIVE_MAX_TRADES_PER_DAY` | 15 | sim 거래 빈도 대비 여유 |

View File

@@ -28,6 +28,15 @@ GT_BUY_PCT_SMALL_LEG=0.05
GT_BUY_PCT_MEDIUM_LEG=0.25 GT_BUY_PCT_MEDIUM_LEG=0.25
GT_LARGE_LEG_TOP_PCT=0.2 GT_LARGE_LEG_TOP_PCT=0.2
GT_MIN_ORDER_KRW=5000 GT_MIN_ORDER_KRW=5000
# 시뮬·GT·paper·live hybrid 배분 공통 초기 자금
GT_INITIAL_CASH_KRW=400000
# 원화 한도 비율 (초기 40만 기준, 100만 시대 대비 ×0.4)
# | 변수 | 비율 | 값 |
# | MONITOR_ALERT_KRW_AMOUNT, LIVE_ORDER_KRW | ×10% | 40,000 |
# | LIVE_DAILY_LOSS_LIMIT_KRW (Phase C) | ×5% | 20,000 |
# | LIVE_DAILY_KRW_MAX (Phase C) | ×10 | 4,000,000 |
# | LIVE_DAILY_KRW_MAX (B-1) | ×1 | 400,000 |
# monitor 규칙 (04 matched_rules — 코드에서 로드, env 변경 불필요) # monitor 규칙 (04 matched_rules — 코드에서 로드, env 변경 불필요)
MATCH_MONITOR_MAX_PER_SIDE=1 MATCH_MONITOR_MAX_PER_SIDE=1
@@ -47,22 +56,36 @@ SIM_TIER_CONVICTION_DD_PCT=10.0
# === Phase C === # === Phase C ===
LIVE_TRADING_ENABLED=0 LIVE_TRADING_ENABLED=0
# 05 알림 # 05 알림 · 06 루프 — 3분봉 1주기(180초)
MONITOR_ALERT_COOLDOWN_MIN=180 MONITOR_ALERT_COOLDOWN_MIN=3
MONITOR_ALERT_KRW_AMOUNT=100000 MONITOR_ALERT_KRW_AMOUNT=40000
MONITOR_LOOP_SLEEP_SEC=180 MONITOR_LOOP_SLEEP_SEC=180
# 06 dry-run (C에서도 06 --once 가능, 주문 없음) # 06 dry-run (시뮬 정합: 일한도·거래횟수 무제한, 쿨다운=봉간격 3분)
LIVE_ORDER_KRW=100000 # LIVE=0 + paper 모드 시 06은 _can_trade에서 LIVE 한도 검사 생략.
LIVE_DAILY_KRW_MAX=300000 LIVE_ORDER_KRW=40000
LIVE_COOLDOWN_MIN=180 LIVE_DAILY_KRW_MAX=4000000
LIVE_MAX_TRADES_PER_DAY=10 LIVE_COOLDOWN_MIN=3
LIVE_DAILY_LOSS_LIMIT_KRW=50000 LIVE_MAX_TRADES_PER_DAY=999
LIVE_DAILY_LOSS_LIMIT_KRW=20000
LIVE_SLIPPAGE_PCT=0.05 LIVE_SLIPPAGE_PCT=0.05
LIVE_BUY_PCT_LARGE=1.0 LIVE_BUY_PCT_LARGE=1.0
LIVE_BUY_PCT_SMALL=0.05 LIVE_BUY_PCT_SMALL=0.05
``` ```
| 항목 | Phase C 값 | 설명 |
|------|------------|------|
| `GT_INITIAL_CASH_KRW` | **400,000** | 시뮬·GT·paper·06 배분 시작 자금 |
| `MONITOR_ALERT_KRW_AMOUNT` | **40,000** | 초기 자금의 10% (알림 참고) |
| `LIVE_ORDER_KRW` | **40,000** | fallback·참고 (실매수는 hybrid 산출) |
| `LIVE_DAILY_KRW_MAX` | **4,000,000** | 초기×10 — Phase C hybrid 전액 매수 여유 |
| `LIVE_DAILY_LOSS_LIMIT_KRW` | **20,000** | 초기×5% — 일 손실 중단 |
| `LIVE_MAX_TRADES_PER_DAY` | 999 | 분할 매수·매도 횟수 제한 없음 |
| `LIVE_COOLDOWN_MIN` | **3** | 규칙별 최소 1봉(3분) — 시뮬 발화 간격과 동일 |
| `MONITOR_ALERT_COOLDOWN_MIN` | **3** | 동일 규칙 텔레그램도 1봉당 1회 수준 |
| `MONITOR_LOOP_SLEEP_SEC` | **180** | 06/05 루프 주기 = 3분 |
| `MATCH_LIVE_CACHE_SEC` | **180** | `live_eval` 캐시 ≤ 루프 주기 (06은 `force_refresh` 사용) |
### Phase C 확인 명령 ### Phase C 확인 명령
```bash ```bash
@@ -82,25 +105,25 @@ python scripts/06_execute_live.py --once
# === Phase B-1 === # === Phase B-1 ===
LIVE_TRADING_ENABLED=1 LIVE_TRADING_ENABLED=1
LIVE_ORDER_KRW=100000 LIVE_ORDER_KRW=40000
LIVE_DAILY_KRW_MAX=1000000 LIVE_DAILY_KRW_MAX=400000
LIVE_MAX_TRADES_PER_DAY=15 LIVE_MAX_TRADES_PER_DAY=15
LIVE_COOLDOWN_MIN=180 LIVE_COOLDOWN_MIN=3
LIVE_DAILY_LOSS_LIMIT_KRW=100000 LIVE_DAILY_LOSS_LIMIT_KRW=40000
LIVE_SLIPPAGE_PCT=0.05 LIVE_SLIPPAGE_PCT=0.05
LIVE_BUY_PCT_LARGE=1.0 LIVE_BUY_PCT_LARGE=1.0
LIVE_BUY_PCT_SMALL=0.05 LIVE_BUY_PCT_SMALL=0.05
MONITOR_ALERT_COOLDOWN_MIN=180 MONITOR_ALERT_COOLDOWN_MIN=3
MONITOR_LOOP_SLEEP_SEC=180 MONITOR_LOOP_SLEEP_SEC=180
``` ```
| 항목 | 값 | 설명 | | 항목 | 값 | 설명 |
|------|-----|------| |------|-----|------|
| 초기 자금 | ₩1,000,000 | 빗썸 가용 KRW | | 초기 자금 | ₩400,000 | 빗썸 가용 KRW (`GT_INITIAL_CASH_KRW`) |
| 일 매수 한도 | ₩1,000,000 | large tier 일부 가능, sim(무한)보다 보수 | | 일 매수 한도 | ₩400,000 | 초기 1배 — large tier 1회분 |
| 일 손실 중단 | ₩100,000 | -10% 일손실 시 당일 추가 주문 중단 | | 일 손실 중단 | ₩40,000 | 초기 10% |
| 1회 참고 | ₩100,000 | 매도 시 참고; **매수는 hybrid tier가 산출** | | 1회 참고 | ₩40,000 | 알림·fallback; **매수는 hybrid tier** |
### B-1 오픈 당일 ### B-1 오픈 당일
@@ -120,11 +143,11 @@ python scripts/06_execute_live.py
# === Phase B-2 === # === Phase B-2 ===
LIVE_TRADING_ENABLED=1 LIVE_TRADING_ENABLED=1
LIVE_ORDER_KRW=100000 LIVE_ORDER_KRW=40000
LIVE_DAILY_KRW_MAX=5000000 LIVE_DAILY_KRW_MAX=2000000
LIVE_MAX_TRADES_PER_DAY=30 LIVE_MAX_TRADES_PER_DAY=30
LIVE_COOLDOWN_MIN=120 LIVE_COOLDOWN_MIN=3
LIVE_DAILY_LOSS_LIMIT_KRW=300000 LIVE_DAILY_LOSS_LIMIT_KRW=120000
LIVE_SLIPPAGE_PCT=0.05 LIVE_SLIPPAGE_PCT=0.05
LIVE_BUY_PCT_LARGE=1.0 LIVE_BUY_PCT_LARGE=1.0
LIVE_BUY_PCT_SMALL=0.05 LIVE_BUY_PCT_SMALL=0.05
@@ -132,7 +155,7 @@ LIVE_BUY_PCT_SMALL=0.05
| sim 대비 | B-1 | B-2 | | sim 대비 | B-1 | B-2 |
|----------|-----|-----| |----------|-----|-----|
| 일한도 | 100만 | 500만 | | 일한도 | 40만 | 200만 |
| large tier 1회 ~100% | 종종 스킵 | 대부분 가능 | | large tier 1회 ~100% | 종종 스킵 | 대부분 가능 |
| +1000% 가능성 | 낮음 | sim에 근접 (보장 없음) | | +1000% 가능성 | 낮음 | sim에 근접 (보장 없음) |

View File

@@ -1,6 +1,6 @@
# Live Phase A — dry-run 검증 # Live Phase A — dry-run 검증
- 일시: 2026-06-01 23:17:30 - 일시: 2026-06-01 23:36:56
- 결과: **PASS** - 결과: **PASS**
## Plan (목적) ## Plan (목적)
@@ -25,53 +25,10 @@ python scripts/06_execute_live.py --once
## Act (다음 단계) ## Act (다음 단계)
1. ~~`LIVE_TRADING_ENABLED=1`~~ **적용 완료 (Phase B-1, 2026-06-01)** 1. `05_run_monitor.py` 1~2일 병행 (알림만)
2. `06_execute_live.py` 상시 루프 기동 (180초 주기) 2. `.env` 파일럿 한도 확정 후 `LIVE_TRADING_ENABLED=1`
3. 1~2주 실계좌 PnL·슬리피지 기록 (본 문서 갱신) 3. 1~2주 실계좌 PnL·슬리피지 기록 (본 문서 갱신)
## Phase C dry-run (지금 ~ 금요일 저녁)
| 항목 | 값 |
|------|-----|
| `LIVE_TRADING_ENABLED` | **0** (실주문 없음) |
| 실행 | `python scripts/06_execute_live.py` (180초 주기) |
| 발화 로그 | `data/ops/paper_fires.jsonl` |
| 금요일 집계 | `python scripts/07_phase_c_paper_report.py` |
### 수익률 확인 (중요)
| 종류 | 가능? |
|------|-------|
| **실계좌 수익률** | **불가** (주문 없음) |
| **모의 forward %** | **가능** (07 스크립트, 발화 후 N봉 가격 기준 **참고용**) |
| **시뮬 hybrid +1,147%** | 과거 1년 백테스트, 이번 주 결과와 **별개** |
금요일 C Go 후 토요일~ **B-1**: `.env`에서 `LIVE_TRADING_ENABLED=1`, `LIVE_DAILY_KRW_MAX=1000000`
### 일별 기록
| 날짜 | download | verify | 06 dry | buy 발화 | sell 발화 | 메모 |
|------|----------|--------|--------|----------|-----------|------|
| 6/1 | | PASS | | | | |
| 6/2 | | | | | | |
| 6/3 | | | | | | |
| 6/4 | | | | | | |
| 6/5 | | | | | | **C Go → B-1** |
---
## Phase B-1 (금요일 이후 예정)
| 항목 | 값 |
|------|-----|
| `LIVE_TRADING_ENABLED` | 1 |
| `LIVE_DAILY_KRW_MAX` | 1,000,000 |
| `LIVE_DAILY_LOSS_LIMIT_KRW` | 100,000 |
| `06 --once` | live=ON, 발화 없음 |
| 배분 | hybrid primary (`enhanced=False`) |
**선행 조치:** `coin` 환경에 `pip install python-dotenv` (`.env` 미적용 방지). `scripts/_bootstrap.py`·`config.py``load_project_env(override=True)`.
## Kill switch ## Kill switch
- `LIVE_TRADING_ENABLED=0` + 06 프로세스 중지 - `LIVE_TRADING_ENABLED=0` + 06 프로세스 중지

View File

@@ -0,0 +1,35 @@
# Live Phase A — dry-run 검증
- 일시: 2026-06-02 22:17:49
- 결과: **PASS**
## Plan (목적)
- hybrid primary(`enhanced=False`) live_trader 경로가 시뮬과 정합인지 확인
- conviction tier(`enhanced=True`) 미사용 확인
- 실거래 한도가 hybrid tier와 어떻게 상호작용하는지 기록
## Do (실행)
```bash
python scripts/06_verify_live_dryrun.py
python scripts/06_execute_live.py --once
```
## Check (점검 결과)
- GT_SIGNAL_CAUSAL=True
- LIVE_TRADING_ENABLED=False
- monitor_rules: buy_compound_tight, sell_mtf_cross_all_tf
- hybrid DD: {'dd_large_pct': 5.0, 'dd_medium_pct': 2.0}
## Act (다음 단계)
1. `05_run_monitor.py` 1~2일 병행 (알림만)
2. `.env` 파일럿 한도 확정 후 `LIVE_TRADING_ENABLED=1`
3. 1~2주 실계좌 PnL·슬리피지 기록 (본 문서 갱신)
## Kill switch
- `LIVE_TRADING_ENABLED=0` + 06 프로세스 중지
- 빗썸 앱 수동 청산

View File

@@ -0,0 +1,39 @@
# Live Phase A — dry-run 검증
- 일시: 2026-06-03 08:50:59
- 결과: **WARN**
## Plan (목적)
- 06 dry-run/live 체결이 `hybrid_sim_execution`(sim_causal_hybrid)과 정합인지 확인
- conviction tier(`enhanced=True`) 미사용 확인
- 실거래 한도가 hybrid tier와 어떻게 상호작용하는지 기록
## Do (실행)
```bash
python scripts/06_verify_live_dryrun.py
python scripts/06_execute_live.py --once
```
## Check (점검 결과)
- GT_SIGNAL_CAUSAL=True
- LIVE_TRADING_ENABLED=False
- monitor_rules: buy_compound_tight, sell_mtf_cross_all_tf
- hybrid DD: {'dd_large_pct': 5.0, 'dd_medium_pct': 2.0}
### 이슈
- paper_portfolio.json 과 sim replay 불일치 — signal_history 갱신 후 06 --once 1회 권장
## Act (다음 단계)
1. `05_run_monitor.py` 1~2일 병행 (알림만)
2. `.env` 파일럿 한도 확정 후 `LIVE_TRADING_ENABLED=1`
3. 1~2주 실계좌 PnL·슬리피지 기록 (본 문서 갱신)
## Kill switch
- `LIVE_TRADING_ENABLED=0` + 06 프로세스 중지
- 빗썸 앱 수동 청산

View File

@@ -12,7 +12,7 @@ dry-run(Phase C)과 실거래(Phase B) 모두 **시뮬 `sim_primary` = `sim_caus
| 배분 | hybrid DD tier + past-leg, **`enhanced=False`** | | 배분 | hybrid DD tier + past-leg, **`enhanced=False`** |
| 금지 | `sim_tier_enhanced` (conviction), GT oracle 타점 | | 금지 | `sim_tier_enhanced` (conviction), GT oracle 타점 |
코드: `deepcoin/ops/live_trader.py` (`GT_SIGNAL_CAUSAL=1``plan_buy_amount_krw(..., enhanced=False)`) 코드: `deepcoin/ops/hybrid_sim_execution.py` (`build_monitor_hybrid_sized_trades`, `enhanced=False`)`live_trader.py`
## Phase C — dry-run (주문 없음, 3단계 전) ## Phase C — dry-run (주문 없음, 3단계 전)
@@ -32,8 +32,9 @@ python scripts/05_run_monitor.py # 텔레그램 알림 (상시)
python scripts/06_execute_live.py --once # dry_run 로그만 (선택) python scripts/06_execute_live.py --once # dry_run 로그만 (선택)
``` ```
- 06은 발화 시 `dry_run (LIVE_TRADING_ENABLED=0)` 기록, **API 매수·매도 호출 없음** - 06은 발화 시 **모의 계좌**(`paper_portfolio.json`)갱신, **빗썸 API 주문 없음**
- 잔고 조회는 알림·tier 계산용으로 API를 쓸 수 있음 - 체결 배분은 시뮬과 동일하게 `signal_history` 전체를 `replay_paper_portfolio`로 재생
- 구버전 paper는 `paper_fires.jsonl``would_trade=true`로 이력 복원 가능
상세: [DEPLOYMENT_CHECKLIST.md](../05_ops/DEPLOYMENT_CHECKLIST.md) §4 · [env.recommended.md](../05_ops/env.recommended.md) Phase C 상세: [DEPLOYMENT_CHECKLIST.md](../05_ops/DEPLOYMENT_CHECKLIST.md) §4 · [env.recommended.md](../05_ops/env.recommended.md) Phase C
@@ -65,27 +66,34 @@ python scripts/06_execute_live.py # 상시
| `LIVE_TRADING_ENABLED` | 0 | 1 | 1일 때만 실주문 | | `LIVE_TRADING_ENABLED` | 0 | 1 | 1일 때만 실주문 |
| `GT_SIGNAL_CAUSAL` | 1 | 1 | hybrid sizing | | `GT_SIGNAL_CAUSAL` | 1 | 1 | hybrid sizing |
| `SIM_PRIMARY_SIZING` | auto | auto | primary=hybrid | | `SIM_PRIMARY_SIZING` | auto | auto | primary=hybrid |
| `LIVE_ORDER_KRW` | 100000 | 100000 | 매도·비-hybrid fallback 참고 | | `GT_INITIAL_CASH_KRW` | **400000** | **400000** | 시뮬·paper·hybrid 배분 시작 자금 |
| `LIVE_DAILY_KRW_MAX` | 300000 | 1,000,000 | **sim 대비 체결 상한** | | `LIVE_ORDER_KRW` | **40000** | **40000** | 초기×10% · fallback 참고 |
| `LIVE_COOLDOWN_MIN` | 180 | 180 | 규칙별 재주문 간격 | | `MONITOR_ALERT_KRW_AMOUNT` | **40000** | **40000** | 알림 참고(초기×10%) |
| `LIVE_MAX_TRADES_PER_DAY` | 10 | 10 | 일 최대 시도 | | `LIVE_DAILY_KRW_MAX` | **4000000** (초기×10) | **400000** | C: hybrid 여유 · B-1: 1일 1배 |
| `LIVE_DAILY_LOSS_LIMIT_KRW` | 50000 | 50000 | 일 손실 한도 | | `LIVE_DAILY_LOSS_LIMIT_KRW` | **20000** | **40000** | C: 초기×5% · B-1: ×10% |
| `MONITOR_LOOP_SLEEP_SEC` | 180 | 180 | 05/06 루프 주기 | | `LIVE_COOLDOWN_MIN` | **3** (1봉) | **3** | `MATCH_PRIMARY_INTERVAL`과 동일 |
| `MONITOR_ALERT_COOLDOWN_MIN` | 180 | 180 | 텔레그램 중복 방지 | | `LIVE_MAX_TRADES_PER_DAY` | **999** (시뮬 정합) | 15 | C: 횟수 제한 없음 |
| `MONITOR_LOOP_SLEEP_SEC` | 180 | 180 | 루프 3분 = 3분봉 주기 |
| `MATCH_LIVE_CACHE_SEC` | **180** | **180** | live_eval 캐시 ≤ 루프 주기 |
| `MONITOR_ALERT_COOLDOWN_MIN` | **3** (1봉) | **3** | 규칙당 알림 최소 간격 |
Phase별 전체 블록: [env.recommended.md](../05_ops/env.recommended.md) Phase별 전체 블록: [env.recommended.md](../05_ops/env.recommended.md)
## 주문·배분 동작 ## 주문·배분 동작
- **매수:** EV/WF 통과 규칙만 hybrid tier 원화 산출 → `_can_trade` (일한도·쿨다운) → 시장가 매수 - **공통:** 발화 이력 → `size_monitor_signals` / `replay_paper_portfolio` (시뮬 `sim_causal_hybrid`와 동일)
- **매:** 보유 WLD 전량 기준 시장가 매도 (`LIVE_ORDER_KRW`는 매도 경로에서 수량 우선) - **매:** EV/WF 통과 규칙만 → `_can_trade` (live: 일한도·3분 쿨다운 / dry-run: 쿨다운만) → planned `amount_krw` 시장가 매수
- **스킵:** 현금 부족, 일한도, hybrid planned > `LIVE_DAILY_KRW_MAX`, 미승인 규칙 - **매도:** 시뮬 분할 `sell_qty` (leg 마지막 매도는 잔량 전량) → 시장가 매도
- **스킵:** 시뮬 배분 0, 보유 없음, 일한도, 미승인 규칙, 규칙 쿨다운(3분)
## 로그 ## 로그
| 경로 | 내용 | | 경로 | 내용 |
|------|------| |------|------|
| `data/ops/live_trades.jsonl` | 06 주문·dry-run 시도 (JSONL) | | `data/ops/live_trades.jsonl` | 06 주문 (JSONL) |
| `data/ops/paper_fires.jsonl` | Phase C 모의 체결 시도 |
| `data/ops/paper_portfolio.json` | dry-run 모의 잔고·`signal_history` |
| `data/ops/live_signal_history.json` | live 발화 이력 (시뮬 배분용) |
| `docs/05_ops/live_verification_*.md` | Phase C/B 일별 기록 | | `docs/05_ops/live_verification_*.md` | Phase C/B 일별 기록 |
## 4단계 연결 ## 4단계 연결

View File

@@ -24,7 +24,7 @@
| Phase | `LIVE_TRADING_ENABLED` | 일한도 예 | 비고 | | Phase | `LIVE_TRADING_ENABLED` | 일한도 예 | 비고 |
|-------|------------------------|-----------|------| |-------|------------------------|-----------|------|
| C (dry-run) | 0 | 30만 (dry-run 참고) | 주문 없음 | | C (dry-run) | 0 | 30만 (dry-run 참고) | 주문 없음 |
| B-1 | 1 | 100만 | sim 대비 보수 | | B-1 | 1 | 40만 | sim 대비 보수 (초기=GT_INITIAL_CASH_KRW) |
| B-2 | 1 | 500만+ | B-1 검증 후만 | | B-2 | 1 | 500만+ | B-1 검증 후만 |
본인 자금·위험 성향에 맞게 **반드시** 낮춰 시작한다. 본인 자금·위험 성향에 맞게 **반드시** 낮춰 시작한다.

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Phase A: live_trader dry-run·hybrid tier 사이징·한도 점검.""" """Phase A: live_trader dry-run·sim_causal_hybrid(06) 정합·한도 점검."""
from __future__ import annotations from __future__ import annotations
@@ -11,13 +11,18 @@ runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
from config import ( # noqa: E402 from config import ( # noqa: E402
CHART_LOOKBACK_DAYS, CHART_LOOKBACK_DAYS,
GT_INITIAL_CASH_KRW,
GT_SIGNAL_CAUSAL, GT_SIGNAL_CAUSAL,
LIVE_COOLDOWN_MIN, LIVE_COOLDOWN_MIN,
LIVE_DAILY_KRW_MAX, LIVE_DAILY_KRW_MAX,
LIVE_DAILY_LOSS_LIMIT_KRW, LIVE_DAILY_LOSS_LIMIT_KRW,
LIVE_MAX_TRADES_PER_DAY, LIVE_MAX_TRADES_PER_DAY,
LIVE_ORDER_KRW,
LIVE_TRADING_ENABLED, LIVE_TRADING_ENABLED,
MATCH_LIVE_CACHE_SEC,
MATCH_PRIMARY_INTERVAL, MATCH_PRIMARY_INTERVAL,
MONITOR_ALERT_KRW_AMOUNT,
MONITOR_LOOP_SLEEP_SEC,
SIM_PRIMARY_SIZING, SIM_PRIMARY_SIZING,
SYMBOL, SYMBOL,
TRADING_FEE_RATE, TRADING_FEE_RATE,
@@ -30,8 +35,11 @@ from deepcoin.ground_truth.gt_model import leg_entry_weights
from deepcoin.matching.position_sizing import compute_buy_amount_krw from deepcoin.matching.position_sizing import compute_buy_amount_krw
from deepcoin.matching.load_rules import load_monitor_rules from deepcoin.matching.load_rules import load_monitor_rules
from deepcoin.matching.live_sizing import LivePositionState, live_sizing_enabled from deepcoin.matching.live_sizing import LivePositionState, live_sizing_enabled
from deepcoin.ops.hybrid_sim_execution import replay_paper_portfolio
from deepcoin.ops.live_trader import LiveTrader from deepcoin.ops.live_trader import LiveTrader
from deepcoin.ops.monitor import Monitor from deepcoin.ops.monitor import Monitor
from deepcoin.ops.paper_portfolio import PaperPortfolio
from deepcoin.matching.position_sizing import load_ev_wf_approved_rule_ids
def _plan_with_dd( def _plan_with_dd(
@@ -87,6 +95,12 @@ def check_config() -> list[str]:
f"loss_limit={LIVE_DAILY_LOSS_LIMIT_KRW:,} " f"loss_limit={LIVE_DAILY_LOSS_LIMIT_KRW:,} "
f"cooldown={LIVE_COOLDOWN_MIN}min" f"cooldown={LIVE_COOLDOWN_MIN}min"
) )
print(
f" 06 루프: sleep={MONITOR_LOOP_SLEEP_SEC}s · "
f"live_eval_cache={MATCH_LIVE_CACHE_SEC}s · bar={MATCH_PRIMARY_INTERVAL}m"
)
print(" 체결 엔진: sim_causal_hybrid (hybrid_sim_execution)")
print(f" GT_INITIAL_CASH_KRW=₩{GT_INITIAL_CASH_KRW:,}")
rules = load_monitor_rules() rules = load_monitor_rules()
print(f" monitor_rules={[r['rule_id'] for r in rules]}") print(f" monitor_rules={[r['rule_id'] for r in rules]}")
if not GT_SIGNAL_CAUSAL: if not GT_SIGNAL_CAUSAL:
@@ -102,15 +116,57 @@ def check_config() -> list[str]:
return issues return issues
def check_capital_alignment() -> list[str]:
"""
초기 자금 40만 원 기준 원화 한도·알림 비율 점검 (100만 시대 ×0.4).
Returns:
불일치 시 이슈 문자열 목록.
"""
issues: list[str] = []
_print_header("1b. 초기 자금·비율 (40만 원)")
ic = int(GT_INITIAL_CASH_KRW)
expected = {
"GT_INITIAL_CASH_KRW": 400_000,
"MONITOR_ALERT_KRW_AMOUNT": int(ic * 0.10),
"LIVE_ORDER_KRW": int(ic * 0.10),
"LIVE_DAILY_LOSS_LIMIT_KRW": int(ic * 0.05),
"LIVE_DAILY_KRW_MAX": int(ic * 10),
}
actual = {
"GT_INITIAL_CASH_KRW": ic,
"MONITOR_ALERT_KRW_AMOUNT": int(MONITOR_ALERT_KRW_AMOUNT),
"LIVE_ORDER_KRW": int(LIVE_ORDER_KRW),
"LIVE_DAILY_LOSS_LIMIT_KRW": int(LIVE_DAILY_LOSS_LIMIT_KRW),
"LIVE_DAILY_KRW_MAX": int(LIVE_DAILY_KRW_MAX),
}
for key, exp in expected.items():
got = actual[key]
ok = got == exp
mark = "OK" if ok else "WARN"
print(f" [{mark}] {key}={got:,} (기대 {exp:,})")
if not ok:
issues.append(f"{key}={got:,} ≠ 기대 {exp:,}")
paper = PaperPortfolio.load()
if int(paper.cash_krw) != ic and paper.qty < 1e-12:
issues.append(
f"paper 현금 ₩{paper.cash_krw:,.0f} ≠ 초기 ₩{ic:,} (보유 없을 때)"
)
elif int(getattr(paper, "initial_cash_krw", 0) or paper.cash_krw) != ic:
print(f" [INFO] paper 운용 중 (cash=₩{paper.cash_krw:,.0f})")
return issues
def check_tier_sizing(df) -> list[str]: def check_tier_sizing(df) -> list[str]:
"""hybrid vs conviction tier 금액 비교 (enhanced=False가 primary).""" """hybrid vs conviction tier 금액 비교 (enhanced=False가 primary)."""
issues: list[str] = [] issues: list[str] = []
_print_header("2. hybrid tier 사이징 (시나리오)") _print_header("2. hybrid tier 사이징 (시나리오)")
price = 487.0 price = 487.0
ic = int(GT_INITIAL_CASH_KRW)
scenarios = [ scenarios = [
("신규·소형DD(1%)", 1_000_000, 0.0, 1.0, {}), ("신규·소형DD(1%)", ic, 0.0, 1.0, {}),
("신규·대형DD(6%)", 1_000_000, 0.0, 6.0, {}), ("신규·대형DD(6%)", ic, 0.0, 6.0, {}),
("복리·과거large leg", 5_000_000, 2000.0, 3.0, {"completed_leg_ret": {1: 25.0}}), ("복리·과거large leg", ic * 5, 2000.0, 3.0, {"completed_leg_ret": {1: 25.0}}),
] ]
for label, cash, qty, dd, extra in scenarios: for label, cash, qty, dd, extra in scenarios:
hybrid_amt = _plan_with_dd( hybrid_amt = _plan_with_dd(
@@ -138,9 +194,35 @@ def check_tier_sizing(df) -> list[str]:
return issues return issues
def check_paper_replay(df) -> list[str]:
"""paper signal_history → 시뮬 replay 잔고 일치."""
issues: list[str] = []
_print_header("3. paper 시뮬 replay")
paper = PaperPortfolio.load()
hist = paper.signal_history
print(f" signal_history={len(hist)} · saved cash=₩{paper.cash_krw:,.0f} qty={paper.qty:.4f}")
if not hist:
print(" (이력 없음 — 신규 dry-run)")
return issues
approved = load_ev_wf_approved_rule_ids()
replayed, _ = replay_paper_portfolio(hist, df, approved_buy_rules=approved)
cash_diff = abs(replayed.cash_krw - paper.cash_krw)
qty_diff = abs(replayed.qty - paper.qty)
print(
f" replay cash=₩{replayed.cash_krw:,.0f} qty={replayed.qty:.4f} "
f"(Δcash={cash_diff:,.0f} Δqty={qty_diff:.6f})"
)
if cash_diff > 1.0 or qty_diff > 1e-6:
issues.append(
"paper_portfolio.json 과 sim replay 불일치 — "
"signal_history 갱신 후 06 --once 1회 권장"
)
return issues
def check_live_limits() -> None: def check_live_limits() -> None:
"""시뮬 대비 실거래 일한도 영향 안내.""" """시뮬 대비 실거래 일한도 영향 안내."""
_print_header("3. 실거래 한도 vs hybrid tier") _print_header("4. 실거래 한도 vs hybrid tier")
st = LivePositionState() st = LivePositionState()
mon = Monitor(cooldown_file=None) mon = Monitor(cooldown_file=None)
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS) frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
@@ -153,7 +235,7 @@ def check_live_limits() -> None:
planned = st.plan_buy_amount_krw( planned = st.plan_buy_amount_krw(
str(df.index[-1]) if df is not None and not df.empty else "2026-06-01 12:00:00", str(df.index[-1]) if df is not None and not df.empty else "2026-06-01 12:00:00",
price, price,
1_000_000, float(GT_INITIAL_CASH_KRW),
0.0, 0.0,
df, df,
enhanced=False, enhanced=False,
@@ -172,7 +254,7 @@ def check_live_limits() -> None:
def check_live_eval() -> None: def check_live_eval() -> None:
"""현재 시점 규칙 발화.""" """현재 시점 규칙 발화."""
_print_header("4. 현재 발화 (live_eval)") _print_header("5. 현재 발화 (live_eval)")
fired = evaluate_live_rules(force_refresh=True) fired = evaluate_live_rules(force_refresh=True)
if not fired: if not fired:
print(" 발화 없음 (정상 — 신호 대기)") print(" 발화 없음 (정상 — 신호 대기)")
@@ -183,7 +265,7 @@ def check_live_eval() -> None:
def run_dryrun_once() -> None: def run_dryrun_once() -> None:
"""06 1회 dry-run.""" """06 1회 dry-run."""
_print_header("5. 06_execute_live --once (dry-run)") _print_header("6. 06_execute_live --once (dry-run)")
LiveTrader().run_once() LiveTrader().run_once()
@@ -199,7 +281,7 @@ def write_verification_report(issues: list[str], out_path: Path) -> None:
"", "",
"## Plan (목적)", "## Plan (목적)",
"", "",
"- hybrid primary(`enhanced=False`) live_trader 경로가 시뮬과 정합인지 확인", "- 06 dry-run/live 체결이 `hybrid_sim_execution`(sim_causal_hybrid)과 정합인지 확인",
"- conviction tier(`enhanced=True`) 미사용 확인", "- conviction tier(`enhanced=True`) 미사용 확인",
"- 실거래 한도가 hybrid tier와 어떻게 상호작용하는지 기록", "- 실거래 한도가 hybrid tier와 어떻게 상호작용하는지 기록",
"", "",
@@ -247,6 +329,7 @@ def main() -> int:
"""Phase A 검증 실행.""" """Phase A 검증 실행."""
print("[06_verify] Phase A dry-run 검증 시작") print("[06_verify] Phase A dry-run 검증 시작")
issues = check_config() issues = check_config()
issues.extend(check_capital_alignment())
mon = Monitor(cooldown_file=None) mon = Monitor(cooldown_file=None)
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS) frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
df = frames.get(MATCH_PRIMARY_INTERVAL) df = frames.get(MATCH_PRIMARY_INTERVAL)
@@ -254,6 +337,7 @@ def main() -> int:
issues.append("3m OHLC 없음 — 01_download 필요") issues.append("3m OHLC 없음 — 01_download 필요")
else: else:
issues.extend(check_tier_sizing(df)) issues.extend(check_tier_sizing(df))
issues.extend(check_paper_replay(df))
check_live_limits() check_live_limits()
check_live_eval() check_live_eval()
run_dryrun_once() run_dryrun_once()

View File

@@ -10,6 +10,7 @@ Phase C dry-run 종료 후 모의 수익률(참고) 집계.
""" """
from __future__ import annotations from __future__ import annotations
import argparse
import json import json
import runpy import runpy
from datetime import datetime from datetime import datetime
@@ -28,7 +29,12 @@ from config import ( # noqa: E402
) )
from deepcoin.matching.label_outcomes import _forward_ret_vectorized # noqa: E402 from deepcoin.matching.label_outcomes import _forward_ret_vectorized # noqa: E402
from deepcoin.ops.monitor import Monitor # noqa: E402 from deepcoin.ops.monitor import Monitor # noqa: E402
from deepcoin.paths import PAPER_FIRES_LOG, PAPER_WEEKLY_REPORT_JSON # noqa: E402 from deepcoin.ops.paper_portfolio import PaperPortfolio # noqa: E402
from deepcoin.paths import ( # noqa: E402
PAPER_FIRES_LOG,
PAPER_WEEKLY_REPORT_JSON,
PHASE_C_DAILY_DIR,
)
_FEE_PCT = TRADING_FEE_RATE * 2 * 100 _FEE_PCT = TRADING_FEE_RATE * 2 * 100
@@ -87,12 +93,22 @@ def attach_forward_returns(fires: pd.DataFrame, close_df: pd.DataFrame) -> pd.Da
return fires return fires
def summarize(fires: pd.DataFrame) -> dict: def summarize(fires: pd.DataFrame, *, report_kind: str = "daily") -> dict:
"""집계 dict.""" """
집계 dict.
Args:
fires: forward_ret_pct 포함 발화 DataFrame.
report_kind: daily | final.
Returns:
JSON 직렬화 가능 dict.
"""
traded = fires[fires["would_trade"] == True] # noqa: E712 traded = fires[fires["would_trade"] == True] # noqa: E712
with_ret = traded[traded["forward_ret_pct"].notna()] with_ret = traded[traded["forward_ret_pct"].notna()]
out: dict = { out: dict = {
"generated_at": datetime.now().isoformat(timespec="seconds"), "generated_at": datetime.now().isoformat(timespec="seconds"),
"report_kind": report_kind,
"symbol": SYMBOL, "symbol": SYMBOL,
"forward_bars": MATCH_FORWARD_BARS, "forward_bars": MATCH_FORWARD_BARS,
"fee_round_trip_pct": _FEE_PCT, "fee_round_trip_pct": _FEE_PCT,
@@ -101,10 +117,17 @@ def summarize(fires: pd.DataFrame) -> dict:
"skipped_count": int(len(fires) - len(traded)), "skipped_count": int(len(fires) - len(traded)),
"labeled_count": int(len(with_ret)), "labeled_count": int(len(with_ret)),
"note": ( "note": (
"모의 forward 수익률. 실계좌·hybrid 복리 PnL 아님. " "forward %는 발화별 참고 지표. "
"매수·매도 leg 미결합 단순 합산." "총보유금액(equity)은 paper_portfolio 모의 체결 기준."
), ),
} }
if not fires.empty and "ts" in fires.columns:
out["log_from"] = str(fires["ts"].min())
out["log_to"] = str(fires["ts"].max())
buy_n = int((traded["side"] == "buy").sum()) if not traded.empty else 0
sell_n = int((traded["side"] == "sell").sum()) if not traded.empty else 0
out["buy_fires"] = buy_n
out["sell_fires"] = sell_n
if not with_ret.empty: if not with_ret.empty:
out["mean_forward_ret_pct"] = round(float(with_ret["forward_ret_pct"].mean()), 4) out["mean_forward_ret_pct"] = round(float(with_ret["forward_ret_pct"].mean()), 4)
out["sum_forward_ret_pct"] = round(float(with_ret["forward_ret_pct"].sum()), 4) out["sum_forward_ret_pct"] = round(float(with_ret["forward_ret_pct"].sum()), 4)
@@ -125,13 +148,25 @@ def summarize(fires: pd.DataFrame) -> dict:
return out return out
def main() -> None: def build_phase_c_report(
"""paper_fires 로드 → forward % → 리포트 저장.""" fires_path: Path | None = None,
fires = load_paper_fires(PAPER_FIRES_LOG) *,
report_kind: str = "daily",
) -> tuple[dict, pd.DataFrame]:
"""
paper_fires 로드 → forward % → 리포트 dict.
Args:
fires_path: jsonl 경로 (기본 PAPER_FIRES_LOG).
report_kind: daily | final.
Returns:
(report, fires_with_returns) — 발화 없으면 ({}, empty DataFrame).
"""
path = fires_path or PAPER_FIRES_LOG
fires = load_paper_fires(path)
if fires.empty: if fires.empty:
print(f"[07] 발화 로그 없음: {PAPER_FIRES_LOG}") return {}, fires
print(" Phase C 기간 06_execute_live.py (LIVE=0) 상시 실행 후 재시도")
return
mon = Monitor(cooldown_file=None) mon = Monitor(cooldown_file=None)
df = mon.read_candles_from_db(SYMBOL, MATCH_PRIMARY_INTERVAL, max_rows=50000) df = mon.read_candles_from_db(SYMBOL, MATCH_PRIMARY_INTERVAL, max_rows=50000)
@@ -141,24 +176,172 @@ def main() -> None:
df = df.set_index(pd.to_datetime(df["datetime"])) df = df.set_index(pd.to_datetime(df["datetime"]))
fires = attach_forward_returns(fires, df) fires = attach_forward_returns(fires, df)
report = summarize(fires) report = summarize(fires, report_kind=report_kind)
PAPER_WEEKLY_REPORT_JSON.parent.mkdir(parents=True, exist_ok=True)
PAPER_WEEKLY_REPORT_JSON.write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(f"[07] 저장: {PAPER_WEEKLY_REPORT_JSON}") mark = float(df["Close"].iloc[-1]) if not df.empty and "Close" in df.columns else 0.0
print(f" 기간 로그: {fires['ts'].min()} ~ {fires['ts'].max()}") paper = PaperPortfolio.load()
print(f" 발화 {report['total_signals']} · 체결가정(would_trade) {report['would_trade_count']}") report["paper_portfolio"] = paper.summary(mark)
return report, fires
def format_report_text(report: dict) -> str:
"""사람이 읽기 쉬운 요약 텍스트."""
kind = report.get("report_kind", "daily")
title = "Phase C 최종 보고" if kind == "final" else "Phase C 중간 보고"
lines = [
f"=== {title} ({report.get('generated_at', '')}) ===",
f"심볼: {report.get('symbol', '')}",
]
pf = report.get("paper_portfolio") or {}
if pf:
lines.extend(
[
"--- 모의 계좌 (dry-run, 빗썸 잔고 미사용) ---",
f"초기 자금: ₩{pf.get('initial_cash_krw', 0):,.0f}",
f"현금: ₩{pf.get('cash_krw', 0):,.0f} · "
f"보유 {pf.get('qty', 0):.4f} {report.get('symbol', '')} "
f"(평가단가 ₩{pf.get('mark_price', 0):,.0f})",
f"코인 평가: ₩{pf.get('coin_value_krw', 0):,.0f}",
f"총보유금액: ₩{pf.get('equity_krw', 0):,.0f} "
f"(손익 ₩{pf.get('pnl_krw', 0):+,.0f} / {pf.get('pnl_pct', 0):+.2f}%)",
]
)
lines.append(
f"발화 합계: {report.get('total_signals', 0)} "
f"(체결 {report.get('would_trade_count', 0)}, "
f"매수 {report.get('buy_fires', 0)} / 매도 {report.get('sell_fires', 0)})"
)
if "log_from" in report:
lines.append(f"로그 구간: {report['log_from']} ~ {report['log_to']}")
if "sum_forward_ret_pct" in report: if "sum_forward_ret_pct" in report:
print( lines.append(
f" 모의 forward 합산: {report['sum_forward_ret_pct']}% " f"모의 forward 합산: {report['sum_forward_ret_pct']}% "
f"(평균 {report['mean_forward_ret_pct']}%, " f"(평균 {report['mean_forward_ret_pct']}%, "
f"{MATCH_FORWARD_BARS}봉 후, 참고용)" f"{report.get('forward_bars')}봉 후, 참고용)"
) )
else: else:
print(" forward 라벨 가능 건 없음 (봉 데이터 부족 또는 발화 없음)") lines.append("모의 forward: 라벨 가능 건 없음 (봉·발화 부족)")
lines.append(report.get("note", ""))
return "\n".join(lines)
def write_report_outputs(
report: dict,
*,
json_path: Path | None = None,
text_path: Path | None = None,
) -> None:
"""JSON·텍스트 리포트 저장."""
if json_path:
json_path.parent.mkdir(parents=True, exist_ok=True)
json_path.write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
if text_path:
text_path.parent.mkdir(parents=True, exist_ok=True)
text_path.write_text(format_report_text(report), encoding="utf-8")
def append_verification_log(report: dict, verification_md: Path) -> None:
"""live_verification 일별 표 해당 날짜 행 갱신."""
if not verification_md.is_file():
return
text = verification_md.read_text(encoding="utf-8")
iso = report.get("generated_at", "")[:10]
try:
y, m, d = iso.split("-")
day_label = f"{int(m)}/{int(d)}"
except ValueError:
return
buy = report.get("buy_fires", 0)
sell = report.get("sell_fires", 0)
pf = report.get("paper_portfolio") or {}
equity = pf.get("equity_krw", "-")
pnl_pct = pf.get("pnl_pct", "-")
kind = report.get("report_kind", "daily")
memo = "C 최종" if kind == "final" else "중간보고"
row = (
f"| {day_label} | Y | - | Y | {buy} | {sell} | "
f"총₩{equity} ({pnl_pct}%) {memo} |"
)
marker = "### 일별 기록"
if marker not in text:
return
head, table = text.split(marker, 1)
lines = table.splitlines()
new_lines: list[str] = []
replaced = False
for line in lines:
if line.startswith(f"| {day_label} |"):
new_lines.append(row)
replaced = True
else:
new_lines.append(line)
if not replaced:
new_lines.append(row)
verification_md.write_text(head + marker + "\n".join(new_lines), encoding="utf-8")
def run_report(
*,
report_kind: str = "daily",
stamp: str | None = None,
update_verification: bool = True,
) -> dict:
"""
리포트 생성·저장·콘솔 출력.
Args:
report_kind: daily | final.
stamp: 파일명용 타임스탬프 (기본 now).
update_verification: live_verification md 갱신 여부.
Returns:
report dict (빈 dict 가능).
"""
report, fires = build_phase_c_report(report_kind=report_kind)
if not report:
print(f"[07] 발화 로그 없음: {PAPER_FIRES_LOG}")
print(" Phase C 기간 06_execute_live.py (LIVE=0) 상시 실행 후 재시도")
return {}
stamp = stamp or datetime.now().strftime("%Y%m%d_%H%M")
daily_dir = PHASE_C_DAILY_DIR
write_report_outputs(
report,
json_path=daily_dir / f"report_{stamp}_{report_kind}.json",
text_path=daily_dir / f"report_{stamp}_{report_kind}.txt",
)
write_report_outputs(report, json_path=PAPER_WEEKLY_REPORT_JSON)
if update_verification:
append_verification_log(
report,
Path(__file__).resolve().parents[1]
/ "docs/05_ops/live_verification_20260601.md",
)
print(format_report_text(report))
print(f"[07] JSON: {PAPER_WEEKLY_REPORT_JSON}")
print(f"[07] 일별: {daily_dir}/report_{stamp}_{report_kind}.*")
return report
def main() -> None:
"""CLI: paper_fires → forward % → 리포트 저장."""
parser = argparse.ArgumentParser(description="Phase C 모의 forward % 집계")
parser.add_argument(
"--kind",
choices=("daily", "final"),
default="daily",
help="daily=중간, final=금요일 최종",
)
parser.add_argument("--no-verification-md", action="store_true")
args = parser.parse_args()
run_report(
report_kind=args.kind,
update_verification=not args.no_verification_md,
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Phase C 슈퍼바이저: 06 dry-run 상시 + 매일 22:00 중간보고 + 금요일 22:00 최종 후 종료.
사용:
python scripts/08_phase_c_supervisor.py
python scripts/08_phase_c_supervisor.py --end-date 2026-06-05 --report-hour 22
"""
from __future__ import annotations
import argparse
import runpy
import signal
import subprocess
import sys
import time
from datetime import date, datetime, timedelta
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[1]
runpy.run_path(str(_ROOT / "scripts" / "_bootstrap.py"))
from config import LIVE_TRADING_ENABLED # noqa: E402
from deepcoin.paths import ( # noqa: E402
PHASE_C_SUPERVISOR_LOG,
PHASE_C_SUPERVISOR_PID,
)
_DEFAULT_PY = "/Users/dsyoon/opt/anaconda3/envs/coin/bin/python"
_REPORT_WINDOW_MIN = 5
def _log(msg: str) -> None:
"""슈퍼바이저 로그 (파일; nohup 시 stdout 중복 방지)."""
line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
PHASE_C_SUPERVISOR_LOG.parent.mkdir(parents=True, exist_ok=True)
with PHASE_C_SUPERVISOR_LOG.open("a", encoding="utf-8") as f:
f.write(line + "\n")
def _run_script(py: str, name: str, *args: str) -> int:
"""scripts/ 하위 스크립트 실행."""
cmd = [py, str(_ROOT / "scripts" / name), *args]
_log(f"실행: {' '.join(cmd)}")
proc = subprocess.run(cmd, cwd=str(_ROOT), check=False)
return proc.returncode
def _in_report_window(now: datetime, hour: int) -> bool:
"""보고 시각(시) 직후 REPORT_WINDOW_MIN 분 이내."""
return now.hour == hour and now.minute < _REPORT_WINDOW_MIN
def _stop_child(proc: subprocess.Popen[bytes] | None) -> None:
"""06 자식 프로세스 종료."""
if proc is None or proc.poll() is not None:
return
_log(f"06 종료 요청 pid={proc.pid}")
proc.send_signal(signal.SIGTERM)
try:
proc.wait(timeout=15)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
_log("06 종료 완료")
def _write_pid() -> None:
"""슈퍼바이저 PID 기록."""
PHASE_C_SUPERVISOR_PID.parent.mkdir(parents=True, exist_ok=True)
PHASE_C_SUPERVISOR_PID.write_text(str(os_getpid()), encoding="utf-8")
def os_getpid() -> int:
"""현재 PID."""
import os
return os.getpid()
def _daily_pipeline(py: str, *, final: bool) -> None:
"""다운로드 → verify → 07 보고."""
_run_script(py, "01_download.py")
_run_script(py, "06_verify_live_dryrun.py")
kind = "final" if final else "daily"
_run_script(py, "07_phase_c_paper_report.py", "--kind", kind)
def main() -> int:
"""
Phase C 슈퍼바이저 메인.
Returns:
종료 코드 0=정상, 1=설정 오류.
"""
parser = argparse.ArgumentParser(description="Phase C dry-run 슈퍼바이저")
parser.add_argument(
"--end-date",
type=lambda s: date.fromisoformat(s),
default=date(2026, 6, 5),
help="최종 보고·종료일 (금요일, ISO)",
)
parser.add_argument("--report-hour", type=int, default=22, help="일일 보고 시각(시, 24h)")
parser.add_argument(
"--py",
default=_DEFAULT_PY,
help="Python 실행 파일 (coin conda)",
)
args = parser.parse_args()
if LIVE_TRADING_ENABLED:
_log("오류: LIVE_TRADING_ENABLED=1 — Phase C는 0 이어야 합니다.")
return 1
_write_pid()
reported: set[date] = set()
py = args.py
end: date = args.end_date
hour: int = args.report_hour
_log(
f"Phase C 슈퍼바이저 시작 · end={end} · 보고 {hour}:00 KST · LIVE=0"
)
child = subprocess.Popen(
[py, str(_ROOT / "scripts" / "06_execute_live.py")],
cwd=str(_ROOT),
)
_log(f"06 dry-run 기동 pid={child.pid}")
try:
while True:
now = datetime.now()
today = now.date()
if child.poll() is not None:
_log(f"06 비정상 종료 code={child.returncode} — 재기동")
child = subprocess.Popen(
[py, str(_ROOT / "scripts" / "06_execute_live.py")],
cwd=str(_ROOT),
)
if _in_report_window(now, hour) and today not in reported:
if today <= end:
reported.add(today)
is_final = today == end
_log(
f"{'최종' if is_final else '중간'} 보고 시작 ({today})"
)
_daily_pipeline(py, final=is_final)
if is_final:
_log("금요일 최종 보고 완료 — Phase C dry-run 종료")
break
if today > end and today not in reported:
_log("종료일 경과 — 최종 보고(미실시 시) 후 종료")
_daily_pipeline(py, final=True)
break
time.sleep(60)
except KeyboardInterrupt:
_log("KeyboardInterrupt — 종료")
finally:
_stop_child(child)
if PHASE_C_SUPERVISOR_PID.is_file():
PHASE_C_SUPERVISOR_PID.unlink(missing_ok=True)
_log("슈퍼바이저 종료")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Phase C: 슈퍼바이저(06 상시 + 22시 보고 + 금요일 종료). 백그라운드 기동용.
set -e
cd "$(dirname "$0")/.."
PY="${PY:-/Users/dsyoon/opt/anaconda3/envs/coin/bin/python}"
LOG="${LOG:-data/ops/phase_c_supervisor.log}"
PIDFILE="${PIDFILE:-data/ops/phase_c_supervisor.pid}"
if [[ -f "$PIDFILE" ]]; then
old=$(cat "$PIDFILE")
if kill -0 "$old" 2>/dev/null; then
echo "이미 실행 중 (pid=$old). 중복 기동하지 않습니다."
exit 0
fi
fi
mkdir -p data/ops
nohup "$PY" -u scripts/08_phase_c_supervisor.py >> "$LOG" 2>&1 &
disown
echo $! > "$PIDFILE"
echo "Phase C 슈퍼바이저 기동 pid=$(cat "$PIDFILE")"
echo "로그: $LOG"

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""
40만 원 기준 매수·매도 최종 리허설 (DB 없이 synthetic + paper replay).
"""
from __future__ import annotations
import runpy
import sys
from pathlib import Path
runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
import pandas as pd
from config import GT_INITIAL_CASH_KRW, TRADING_FEE_RATE
from deepcoin.matching.position_sizing import load_ev_wf_approved_rule_ids
from deepcoin.ops.hybrid_sim_execution import (
hit_key,
plan_live_hit,
replay_paper_portfolio,
sort_hits_sim_order,
)
from deepcoin.ops.paper_portfolio import PaperPortfolio
def _mini_ohlc() -> pd.DataFrame:
"""drawdown 계산용 최소 3m OHLC."""
idx = pd.date_range("2026-06-01 09:00:00", periods=200, freq="3min")
close = pd.Series([500.0 - i * 0.1 for i in range(200)], index=idx, dtype=float)
return pd.DataFrame(
{"Open": close, "High": close + 2, "Low": close - 2, "Close": close},
index=idx,
)
def test_sort_buy_before_sell() -> None:
"""동일 시각 buy·sell → buy 먼저."""
hits = [
{"dt": "2026-06-01 12:00:00", "rule_id": "sell_mtf_cross_all_tf", "side": "sell", "close": 500.0},
{"dt": "2026-06-01 12:00:00", "rule_id": "buy_compound_tight", "side": "buy", "close": 500.0},
]
ordered = sort_hits_sim_order(hits)
assert ordered[0]["side"] == "buy", ordered
print(" [OK] 동일 시각 buy → sell 순서")
def test_sell_without_holdings() -> None:
"""보유 없이 매도만 → 모의 보유 없음."""
ohlc = _mini_ohlc()
approved = load_ev_wf_approved_rule_ids()
hist = [
{
"dt": "2026-06-01 12:00:00",
"rule_id": "sell_mtf_cross_all_tf",
"side": "sell",
"close": 500.0,
}
]
paper, results = replay_paper_portfolio(hist, ohlc, approved_buy_rules=approved)
key = hit_key(hist[0])
res = results[key]
assert not res.ok and "보유 없음" in res.message, res
assert paper.qty < 1e-9 and paper.cash_krw == float(GT_INITIAL_CASH_KRW)
print(" [OK] 보유 없음 매도 스킵")
def test_buy_then_partial_sell() -> None:
"""매수 후 분할 매도 1회."""
ohlc = _mini_ohlc()
approved = load_ev_wf_approved_rule_ids()
dt_buy = str(ohlc.index[50])
dt_sell = str(ohlc.index[80])
price_buy = float(ohlc.loc[ohlc.index[50], "Close"])
price_sell = float(ohlc.loc[ohlc.index[80], "Close"])
hist = [
{
"dt": dt_buy,
"rule_id": "buy_compound_tight",
"side": "buy",
"close": price_buy,
},
{
"dt": dt_sell,
"rule_id": "sell_mtf_cross_all_tf",
"side": "sell",
"close": price_sell,
},
]
paper, results = replay_paper_portfolio(hist, ohlc, approved_buy_rules=approved)
buy_res = results[hit_key(hist[0])]
sell_res = results[hit_key(hist[1])]
assert buy_res.ok, buy_res.message
assert paper.qty > 0 or sell_res.ok, (paper.qty, sell_res)
if sell_res.ok:
assert sell_res.sell_qty > 0 and sell_res.amount_krw > 0
assert paper.cash_krw > float(GT_INITIAL_CASH_KRW) * 0.5
print(
f" [OK] 매수 ₩{buy_res.amount_krw:,.0f} → 매도 "
f"ok={sell_res.ok} qty={sell_res.sell_qty:.4f} 현금=₩{paper.cash_krw:,.0f}"
)
def test_unapproved_buy_excluded_from_sizing() -> None:
"""EV/WF 미포함 매수는 hybrid 배분 입력에서 제외."""
ohlc = _mini_ohlc()
hist = [
{
"dt": str(ohlc.index[60]),
"rule_id": "buy_fake_rule",
"side": "buy",
"close": 500.0,
},
]
approved = {"buy_compound_tight"}
sized_hist = replay_paper_portfolio(hist, ohlc, approved_buy_rules=approved)[0]
assert sized_hist.qty < 1e-9
print(" [OK] 미승인 매수 규칙 → 체결 없음")
def test_plan_live_matches_replay() -> None:
"""plan_live_hit == replay 마지막 건."""
ohlc = _mini_ohlc()
approved = load_ev_wf_approved_rule_ids()
hist = []
hit = {
"dt": str(ohlc.index[70]),
"rule_id": "buy_compound_tight",
"side": "buy",
"close": float(ohlc["Close"].iloc[70]),
}
plan = plan_live_hit(hist, hit, ohlc, approved_buy_rules=approved)
hist.append(hit)
_, results = replay_paper_portfolio(hist, ohlc, approved_buy_rules=approved)
replay_res = results[hit_key(hit)]
assert plan.amount_krw == replay_res.amount_krw, (plan, replay_res)
assert plan.ok == replay_res.ok
print(f" [OK] plan_live_hit ≡ replay (₩{plan.amount_krw:,.0f})")
def test_initial_cash_400k_large_buy() -> None:
"""40만·대형 DD 시 매수액 ≤ 가용현금."""
ohlc = _mini_ohlc()
approved = load_ev_wf_approved_rule_ids()
hit = {
"dt": str(ohlc.index[100]),
"rule_id": "buy_compound_tight",
"side": "buy",
"close": float(ohlc["Close"].iloc[100]),
}
plan = plan_live_hit([], hit, ohlc, approved_buy_rules=approved)
assert plan.ok
assert 0 < plan.amount_krw <= GT_INITIAL_CASH_KRW
fee = plan.amount_krw * TRADING_FEE_RATE
assert plan.amount_krw + fee <= GT_INITIAL_CASH_KRW + 1
print(f" [OK] 40만 대형 tier 매수 ₩{plan.amount_krw:,.0f} (≤{GT_INITIAL_CASH_KRW:,})")
def test_paper_apply_buy_insufficient() -> None:
"""현금 부족 시 apply_buy 실패."""
p = PaperPortfolio()
p.cash_krw = 10_000.0
ok = p.apply_buy(50_000, 500.0, leg_id=1)
assert not ok
print(" [OK] 현금 부족 매수 거부")
def main() -> int:
"""리허설 실행."""
print(f"[리허설] GT_INITIAL_CASH_KRW=₩{GT_INITIAL_CASH_KRW:,}")
print(f" approved buys: {load_ev_wf_approved_rule_ids()}")
fails = 0
tests = [
test_sort_buy_before_sell,
test_sell_without_holdings,
test_buy_then_partial_sell,
test_unapproved_buy_excluded_from_sizing,
test_plan_live_matches_replay,
test_initial_cash_400k_large_buy,
test_paper_apply_buy_insufficient,
]
for fn in tests:
try:
fn()
except AssertionError as e:
print(f" [FAIL] {fn.__name__}: {e}")
fails += 1
except Exception as e:
print(f" [ERROR] {fn.__name__}: {e}")
fails += 1
print(f"\n[결과] {'PASS' if fails == 0 else f'FAIL ({fails})'}")
return 1 if fails else 0
if __name__ == "__main__":
raise SystemExit(main())