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:
25
.env.example
25
.env.example
@@ -14,7 +14,8 @@ DOWNLOAD_MONTHS=12
|
||||
# 05/06 루프마다 API 봉을 coins.db에 증분 저장 (01과 동일 append_data)
|
||||
MONITOR_PERSIST_CANDLES=1
|
||||
|
||||
# 02 Ground Truth
|
||||
# 02 Ground Truth · 시뮬·dry-run·live 배분 공통 초기 자금
|
||||
GT_INITIAL_CASH_KRW=400000
|
||||
GT_MIN_ORDER_KRW=5000
|
||||
GT_BUY_PCT_LARGE_LEG=1.0
|
||||
GT_BUY_PCT_SMALL_LEG=0.05
|
||||
@@ -53,20 +54,22 @@ SIM_GO_WF_POSITIVE_RATIO=0.5
|
||||
SIM_FEE_STRESS_MULT=2.0
|
||||
|
||||
# 05 알림 (Phase C: MONITOR_LOOP_SLEEP_SEC=180 권장)
|
||||
MONITOR_ALERT_COOLDOWN_MIN=180
|
||||
MONITOR_ALERT_KRW_AMOUNT=100000
|
||||
# 쿨다운 = 최소 봉 간격(3분)과 동일. 루프 주기 MONITOR_LOOP_SLEEP_SEC=180
|
||||
MONITOR_ALERT_COOLDOWN_MIN=3
|
||||
MONITOR_ALERT_KRW_AMOUNT=40000
|
||||
MONITOR_LOOP_SLEEP_SEC=180
|
||||
MATCH_LIVE_CACHE_SEC=180
|
||||
|
||||
# 3 실거래 — Phase별 권장값: docs/05_ops/env.recommended.md
|
||||
# Phase C (알림만): LIVE_TRADING_ENABLED=0
|
||||
# Phase B-1 (소액 live): LIVE_TRADING_ENABLED=1, LIVE_DAILY_KRW_MAX=1000000
|
||||
# Phase B-2 (sim 근접): LIVE_DAILY_KRW_MAX=5000000
|
||||
# Phase C (dry-run): LIVE=0, LIVE_* 무제한(시뮬 정합), COOLDOWN=3(1봉)
|
||||
# Phase B-1: LIVE=1, LIVE_DAILY_KRW_MAX=400000, MAX_TRADES=15, COOLDOWN=3
|
||||
# Phase B-2: LIVE_DAILY_KRW_MAX=5000000, MAX_TRADES=30, COOLDOWN=120
|
||||
LIVE_TRADING_ENABLED=0
|
||||
LIVE_ORDER_KRW=100000
|
||||
LIVE_ORDER_KRW=40000
|
||||
LIVE_BUY_PCT_LARGE=1.0
|
||||
LIVE_BUY_PCT_SMALL=0.05
|
||||
LIVE_DAILY_KRW_MAX=300000
|
||||
LIVE_COOLDOWN_MIN=180
|
||||
LIVE_MAX_TRADES_PER_DAY=10
|
||||
LIVE_DAILY_LOSS_LIMIT_KRW=50000
|
||||
LIVE_DAILY_KRW_MAX=4000000
|
||||
LIVE_COOLDOWN_MIN=3
|
||||
LIVE_MAX_TRADES_PER_DAY=999
|
||||
LIVE_DAILY_LOSS_LIMIT_KRW=20000
|
||||
LIVE_SLIPPAGE_PCT=0.05
|
||||
|
||||
19
config.py
19
config.py
@@ -208,7 +208,7 @@ GT_SELL_SPLIT_WEIGHTS: tuple[float, ...] = tuple(
|
||||
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_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_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")
|
||||
@@ -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")
|
||||
|
||||
# --- 모니터 / 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_DEFAULT_INTERVAL = _getenv_int("MONITOR_DEFAULT_INTERVAL", "60")
|
||||
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_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)
|
||||
MONITOR_PERSIST_CANDLES = _getenv("MONITOR_PERSIST_CANDLES", "1").strip().lower() in (
|
||||
"1",
|
||||
@@ -341,7 +341,7 @@ MATCH_INCLUDE_MTF_CROSS = _getenv("MATCH_INCLUDE_MTF_CROSS", "1").strip() in (
|
||||
"yes",
|
||||
)
|
||||
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_MAX_HOLD_DAYS = _getenv_int("MATCH_MAX_HOLD_DAYS", "45")
|
||||
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_MIN_FIRES_HOLDOUT = _getenv_int("MATCH_MIN_FIRES_HOLDOUT", "5")
|
||||
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(
|
||||
x.strip()
|
||||
for x in _getenv(
|
||||
@@ -388,11 +388,12 @@ LIVE_TRADING_ENABLED = _getenv("LIVE_TRADING_ENABLED", "0").strip() in (
|
||||
"True",
|
||||
"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_SMALL = _getenv_float("LIVE_BUY_PCT_SMALL", "0.05")
|
||||
LIVE_DAILY_KRW_MAX = _getenv_int("LIVE_DAILY_KRW_MAX", "300000")
|
||||
LIVE_COOLDOWN_MIN = _getenv_int("LIVE_COOLDOWN_MIN", "180")
|
||||
# Phase C dry-run: 초기자금×10 (hybrid full tier 여유). B-1은 .env에서 400000 권장.
|
||||
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_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")
|
||||
|
||||
7
data/ops/live_sizing_state.json
Normal file
7
data/ops/live_sizing_state.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"current_leg_id": 0,
|
||||
"open_buys": [],
|
||||
"completed_leg_ret": {},
|
||||
"leg_cost_krw": 0.0,
|
||||
"leg_proceeds_krw": 0.0
|
||||
}
|
||||
@@ -1185,7 +1185,7 @@ def simulate_truth_portfolio(
|
||||
|
||||
Args:
|
||||
trades: JSON trades 또는 TradePoint 리스트.
|
||||
initial_cash: 시작 원화 (기본 100만).
|
||||
initial_cash: 시작 원화 (기본 GT_INITIAL_CASH_KRW, 40만).
|
||||
fee_rate: 매수·매도 각각 적용 수수료율.
|
||||
last_price: 미청산 평가용 종가. None이면 마지막 체결가.
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ def build_simulation_report(
|
||||
use_amount_krw=True,
|
||||
)
|
||||
|
||||
# 전기간 monitor 규칙 — 100만원에서 복리 (holdout만 X)
|
||||
# 전기간 monitor 규칙 — GT_INITIAL_CASH_KRW에서 복리 (holdout만 X)
|
||||
all_monitor = outcomes[outcomes["rule_id"].isin(monitor_ids)]
|
||||
if not all_monitor.empty:
|
||||
sim_trades_full = fires_to_trade_list(sort_fires_chronological(all_monitor))
|
||||
|
||||
@@ -47,16 +47,21 @@ def _holding_qty(balances: dict[str, dict[str, float]], symbol: str) -> float:
|
||||
def build_rule_alert_message(
|
||||
hit: dict[str, Any],
|
||||
balances: dict[str, dict[str, float]] | None = None,
|
||||
*,
|
||||
trade_krw: float | None = None,
|
||||
trade_qty: float | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
규칙 발화 알림 본문을 만듭니다.
|
||||
|
||||
매수: MONITOR_ALERT_KRW_AMOUNT 기준 수량·금액.
|
||||
매도: 보유 수량(잔고 조회 가능 시) × 가격 = 금액, 없으면 참고 금액 기준.
|
||||
trade_krw·trade_qty가 있으면 실제(모의) 체결 규모를 표시합니다.
|
||||
없으면 매수는 MONITOR_ALERT_KRW_AMOUNT, 매도는 보유×가격(또는 참고 금액).
|
||||
|
||||
Args:
|
||||
hit: evaluate_live_rules 항목 (side, rule_id, dt, close).
|
||||
balances: 빗썸 잔고 dict. None이면 매도도 참고 금액 기준.
|
||||
balances: 잔고 dict (모의·실거래).
|
||||
trade_krw: 체결 원화(모의·실거래 planned/executed).
|
||||
trade_qty: 체결 수량(매도 시).
|
||||
|
||||
Returns:
|
||||
텔레그램 메시지 문자열.
|
||||
@@ -67,14 +72,25 @@ def build_rule_alert_message(
|
||||
dt = hit.get("dt", "")
|
||||
|
||||
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)
|
||||
amount = qty * close
|
||||
qty_basis = "보유 기준"
|
||||
qty_basis = "보유 기준(체결 전)"
|
||||
elif side == "BUY":
|
||||
amount = float(MONITOR_ALERT_KRW_AMOUNT)
|
||||
qty = amount / close if close > 0 else 0.0
|
||||
qty_basis = "참고 매수 규모"
|
||||
qty_basis = "참고 매수 규모(알림용)"
|
||||
else:
|
||||
amount = float(MONITOR_ALERT_KRW_AMOUNT)
|
||||
qty = amount / close if close > 0 else 0.0
|
||||
|
||||
255
deepcoin/ops/hybrid_sim_execution.py
Normal file
255
deepcoin/ops/hybrid_sim_execution.py
Normal 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, "시뮬 배분 없음")
|
||||
@@ -1,5 +1,7 @@
|
||||
"""
|
||||
3단계: monitor_rules 발화 시 빗썸 실주문 (가드·로그).
|
||||
|
||||
dry-run·live 체결 배분: 시뮬 sim_causal_hybrid 와 동일 (hybrid_sim_execution).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,6 +15,7 @@ from typing import Any
|
||||
from config import (
|
||||
CHART_LOOKBACK_DAYS,
|
||||
COIN_NAME,
|
||||
GT_INITIAL_CASH_KRW,
|
||||
LIVE_COOLDOWN_MIN,
|
||||
LIVE_DAILY_KRW_MAX,
|
||||
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.load_rules import load_monitor_rules
|
||||
from deepcoin.matching.position_sizing import (
|
||||
compute_buy_amount_krw,
|
||||
live_buy_asset_pct_scale,
|
||||
load_ev_wf_approved_rule_ids,
|
||||
top_leg_ids_by_forward_return,
|
||||
)
|
||||
from deepcoin.paths import resolve_ground_truth_file
|
||||
from deepcoin.ops.alert_message import build_rule_alert_message
|
||||
from deepcoin.ops.hybrid_sim_execution import (
|
||||
hit_key,
|
||||
plan_live_hit,
|
||||
replay_paper_portfolio,
|
||||
sort_hits_sim_order,
|
||||
)
|
||||
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):
|
||||
"""
|
||||
규칙 발화 시 실거래 실행. LIVE_TRADING_ENABLED=0 이면 주문 없음(드라이런 로그만).
|
||||
규칙 발화 시 실거래 실행. LIVE_TRADING_ENABLED=0 이면 모의(sim hybrid)만.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -57,8 +69,73 @@ class LiveTrader(Monitor):
|
||||
self._large_legs: set[int] = set()
|
||||
self._approved_rules: set[str] = set()
|
||||
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._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:
|
||||
"""GT leg·EV/WF 통과 규칙 캐시."""
|
||||
@@ -77,12 +154,7 @@ class LiveTrader(Monitor):
|
||||
self._day_pnl_krw = 0.0
|
||||
|
||||
def _append_log(self, record: dict[str, Any]) -> None:
|
||||
"""
|
||||
live_trades.jsonl에 한 줄 append.
|
||||
|
||||
Args:
|
||||
record: 로그 dict.
|
||||
"""
|
||||
"""live_trades.jsonl append."""
|
||||
LIVE_TRADES_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with LIVE_TRADES_LOG.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
@@ -95,11 +167,7 @@ class LiveTrader(Monitor):
|
||||
skip_reason: str = "",
|
||||
order_log: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Phase C dry-run: 모든 발화·스킵 사유·모의 금액을 paper_fires.jsonl에 기록.
|
||||
|
||||
금요일 `07_phase_c_paper_report.py`로 forward 수익률(참고) 집계.
|
||||
"""
|
||||
"""Phase C paper_fires.jsonl."""
|
||||
PAPER_FIRES_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
row = {
|
||||
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||
@@ -112,21 +180,21 @@ class LiveTrader(Monitor):
|
||||
"skip_reason": skip_reason or "",
|
||||
"live_enabled": bool(LIVE_TRADING_ENABLED),
|
||||
"order_message": (order_log or {}).get("message", ""),
|
||||
"sizing": "sim_causal_hybrid",
|
||||
}
|
||||
with PAPER_FIRES_LOG.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
|
||||
def _can_trade(self, rule_id: str, planned_krw: float | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
일·쿨다운·손실 한도 검사.
|
||||
|
||||
Args:
|
||||
rule_id: 규칙 ID.
|
||||
|
||||
Returns:
|
||||
(허용 여부, 사유).
|
||||
쿨다운(1봉=3분) + live 일한도. dry-run은 일한도만 생략.
|
||||
"""
|
||||
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:
|
||||
return False, "일 최대 거래 수 초과"
|
||||
need = float(planned_krw if planned_krw is not None else LIVE_ORDER_KRW)
|
||||
@@ -134,138 +202,94 @@ class LiveTrader(Monitor):
|
||||
return False, "일 주문 한도 초과"
|
||||
if self._day_pnl_krw <= -abs(LIVE_DAILY_LOSS_LIMIT_KRW):
|
||||
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, ""
|
||||
|
||||
def _load_ohlc_df(self) -> None:
|
||||
"""drawdown tier용 3m OHLC 캐시."""
|
||||
"""drawdown tier용 3m OHLC."""
|
||||
try:
|
||||
frames = load_frames_from_db(self, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
|
||||
self._ohlc_df = frames.get(MATCH_PRIMARY_INTERVAL)
|
||||
except Exception:
|
||||
self._ohlc_df = None
|
||||
|
||||
def _resolve_buy_amount_krw(self, hit: dict[str, Any]) -> float:
|
||||
"""
|
||||
총자산·현금·EV/WF·leg 티어로 매수 원화 산출.
|
||||
|
||||
GT_SIGNAL_CAUSAL=1 이면 시뮬 sim_primary(hybrid, enhanced=False)와 동일 tier·weight.
|
||||
|
||||
Args:
|
||||
hit: evaluate_live_rules 항목.
|
||||
|
||||
Returns:
|
||||
매수 원화.
|
||||
"""
|
||||
rid = hit["rule_id"]
|
||||
if rid not in self._approved_rules:
|
||||
return 0.0
|
||||
price = float(hit["close"])
|
||||
cash = 0.0
|
||||
qty = 0.0
|
||||
try:
|
||||
bal = self.load_balances_dict()
|
||||
sym = bal.get(SYMBOL, {})
|
||||
cash = float(sym.get("available_krw") or sym.get("krw") or 0)
|
||||
qty = float(sym.get("balance") or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
if live_sizing_enabled():
|
||||
def _resync_paper_from_sim(self) -> None:
|
||||
"""기존 paper 잔고를 sim_causal_hybrid replay 로 맞춤."""
|
||||
if self._ohlc_df is None:
|
||||
self._load_ohlc_df()
|
||||
return self._position_state.plan_buy_amount_krw(
|
||||
hit["dt"],
|
||||
price,
|
||||
cash,
|
||||
qty,
|
||||
if self._ohlc_df is None or getattr(self._ohlc_df, "empty", True):
|
||||
return
|
||||
replayed, _ = replay_paper_portfolio(
|
||||
self._paper.signal_history,
|
||||
self._ohlc_df,
|
||||
enhanced=False,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
approved_buy_rules=self._approved_rules,
|
||||
)
|
||||
scale = live_buy_asset_pct_scale(
|
||||
rid,
|
||||
hit["dt"],
|
||||
self._gt_trades,
|
||||
approved_rules=self._approved_rules,
|
||||
large_legs=self._large_legs,
|
||||
)
|
||||
return compute_buy_amount_krw(
|
||||
cash,
|
||||
qty,
|
||||
price,
|
||||
1.0,
|
||||
1.0,
|
||||
asset_pct_scale=scale,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
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
|
||||
self._paper.save()
|
||||
|
||||
def _sim_plan(self, hit: dict[str, Any]) -> Any:
|
||||
"""시뮬 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]:
|
||||
"""
|
||||
매수·매도 주문 실행 또는 드라이런.
|
||||
|
||||
Args:
|
||||
hit: evaluate_live_rules 항목.
|
||||
|
||||
Returns:
|
||||
로그용 결과 dict.
|
||||
"""
|
||||
def _execute_live_order(self, hit: dict[str, Any], plan: Any) -> dict[str, Any]:
|
||||
"""실거래: 시뮬 planned 금액·수량으로 API 주문."""
|
||||
side = hit["side"]
|
||||
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] = {
|
||||
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||
"rule_id": hit["rule_id"],
|
||||
"side": side,
|
||||
"signal_dt": hit["dt"],
|
||||
"price": price,
|
||||
"amount_krw": amount_krw,
|
||||
"live_enabled": LIVE_TRADING_ENABLED,
|
||||
"amount_krw": plan.amount_krw,
|
||||
"live_enabled": True,
|
||||
"ok": False,
|
||||
"message": "",
|
||||
"message": plan.message,
|
||||
"sizing": "sim_causal_hybrid",
|
||||
}
|
||||
|
||||
if not LIVE_TRADING_ENABLED:
|
||||
record["message"] = "dry_run (LIVE_TRADING_ENABLED=0)"
|
||||
record["ok"] = True
|
||||
if not plan.ok:
|
||||
return record
|
||||
|
||||
try:
|
||||
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["message"] = "buyCoinMarket" if ok else "buy failed"
|
||||
elif side == "sell":
|
||||
bal = self.load_balances_dict().get(SYMBOL, {})
|
||||
qty = float(bal.get("balance") or 0)
|
||||
if qty <= 0:
|
||||
held = float(bal.get("balance") or 0)
|
||||
if held <= 0:
|
||||
record["message"] = "보유 없음"
|
||||
else:
|
||||
gross = qty * price
|
||||
sell_qty = min(float(plan.sell_qty), held)
|
||||
if sell_qty <= 0:
|
||||
record["message"] = "매도 수량 0"
|
||||
else:
|
||||
gross = sell_qty * price
|
||||
record["amount_krw"] = round(gross, 0)
|
||||
ok = self.sellCoinMarket(SYMBOL, int(price), qty)
|
||||
ok = self.sellCoinMarket(SYMBOL, int(price), sell_qty)
|
||||
record["ok"] = bool(ok)
|
||||
record["message"] = f"sell qty={qty}" if ok else "sell failed"
|
||||
record["sell_qty"] = sell_qty
|
||||
record["message"] = (
|
||||
f"sell qty={sell_qty:.4f}" if ok else "sell failed"
|
||||
)
|
||||
if record["ok"] and live_sizing_enabled():
|
||||
fee = gross * TRADING_FEE_RATE
|
||||
self._position_state.record_sell(
|
||||
gross, fee, full_close=True
|
||||
gross, fee, full_close=(sell_qty >= held * 0.999)
|
||||
)
|
||||
self._position_state.save()
|
||||
else:
|
||||
@@ -274,7 +298,7 @@ class LiveTrader(Monitor):
|
||||
record["message"] = str(exc)
|
||||
|
||||
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_trades += 1
|
||||
self._rule_last_unix[hit["rule_id"]] = time.time()
|
||||
@@ -282,74 +306,158 @@ class LiveTrader(Monitor):
|
||||
fee = spent * TRADING_FEE_RATE
|
||||
self._position_state.record_buy(hit["dt"], price, spent, fee)
|
||||
self._position_state.save()
|
||||
self._append_live_signal(hit)
|
||||
self._save_live_signal_history()
|
||||
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:
|
||||
"""1회: 규칙 평가 → (허용 시) 주문 → 텔레그램."""
|
||||
"""1회: 규칙 평가 → 시뮬 hybrid 체결 → 텔레그램."""
|
||||
rules = load_monitor_rules()
|
||||
print(
|
||||
f"[06] {datetime.now():%Y-%m-%d %H:%M:%S} "
|
||||
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:
|
||||
print(" monitor_rules 없음")
|
||||
return
|
||||
|
||||
fired = evaluate_live_rules(rules)
|
||||
balances = None
|
||||
try:
|
||||
balances = self.load_balances_dict()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
fired = evaluate_live_rules(rules, force_refresh=True)
|
||||
if not fired:
|
||||
print(" 발화 없음")
|
||||
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"]
|
||||
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(" skip: EV/WF 미통과 규칙")
|
||||
self._append_paper_fire(
|
||||
hit, 0.0, False, "EV/WF 미통과 규칙"
|
||||
)
|
||||
if self._paper_mode:
|
||||
self._append_paper_fire(hit, 0.0, False, "EV/WF 미통과 규칙")
|
||||
self._paper.mark_processed(rid, hit["dt"])
|
||||
continue
|
||||
planned = (
|
||||
self._resolve_buy_amount_krw(hit)
|
||||
if hit["side"] == "buy"
|
||||
else float(LIVE_ORDER_KRW)
|
||||
)
|
||||
ok, reason = self._can_trade(rid, planned)
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
|
||||
|
||||
plan_preview = self._sim_plan(hit)
|
||||
ok, reason = self._can_trade(rid, plan_preview.amount_krw)
|
||||
if not ok:
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
|
||||
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
|
||||
if hit["side"] == "buy" and planned <= 0:
|
||||
print(" skip: 매수금액 0")
|
||||
self._append_paper_fire(hit, 0.0, False, "매수금액 0")
|
||||
|
||||
if self._paper_mode:
|
||||
new_paper_hits.append(hit)
|
||||
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)
|
||||
print(f" order: {log['message']} ok={log['ok']}")
|
||||
msg = build_rule_alert_message(hit, balances)
|
||||
if log["ok"]:
|
||||
if not log["ok"]:
|
||||
continue
|
||||
balances = self._balances_for_trading()
|
||||
msg = build_rule_alert_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']}"
|
||||
else:
|
||||
msg += f"\n[실패] {log['message']}"
|
||||
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:
|
||||
sleep_sec: 대기 초.
|
||||
"""
|
||||
def run_loop(self, sleep_sec: int) -> None:
|
||||
"""상시 루프."""
|
||||
print(f"[06] 실거래 루프 시작 · sleep={sleep_sec}s")
|
||||
while True:
|
||||
self.run_once()
|
||||
time.sleep(sleep_sec)
|
||||
|
||||
|
||||
def _fmt_paper_krw(value: float) -> str:
|
||||
"""원화 표시."""
|
||||
return f"₩{float(value):,.0f}"
|
||||
|
||||
337
deepcoin/ops/paper_portfolio.py
Normal file
337
deepcoin/ops/paper_portfolio.py
Normal 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)",
|
||||
}
|
||||
@@ -54,7 +54,12 @@ MATCHING_GT_COMPARISON_HTML = DOCS_MATCHING / "gt_comparison_report.html"
|
||||
|
||||
LIVE_TRADES_LOG = OPS_STATE_DIR / "live_trades.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"
|
||||
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_TRUTH_HTML = DOCS_GROUND_TRUTH / "wld_ground_truth_chart.html"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -6,75 +6,75 @@
|
||||
"walk_forward": [
|
||||
{
|
||||
"month": "2025-06",
|
||||
"pnl_pct": 12.78,
|
||||
"start_asset_krw": 1000000.0,
|
||||
"end_asset_krw": 1127825.0
|
||||
"pnl_pct": 10.02,
|
||||
"start_asset_krw": 400000.0,
|
||||
"end_asset_krw": 440064.0
|
||||
},
|
||||
{
|
||||
"month": "2025-07",
|
||||
"pnl_pct": 60.89,
|
||||
"start_asset_krw": 1127825.0,
|
||||
"end_asset_krw": 1814584.0
|
||||
"start_asset_krw": 440064.0,
|
||||
"end_asset_krw": 708031.0
|
||||
},
|
||||
{
|
||||
"month": "2025-08",
|
||||
"pnl_pct": 22.9,
|
||||
"start_asset_krw": 1814584.0,
|
||||
"end_asset_krw": 2230083.0
|
||||
"start_asset_krw": 708031.0,
|
||||
"end_asset_krw": 870154.0
|
||||
},
|
||||
{
|
||||
"month": "2025-09",
|
||||
"pnl_pct": 57.63,
|
||||
"start_asset_krw": 2230083.0,
|
||||
"end_asset_krw": 3515283.0
|
||||
"pnl_pct": 58.24,
|
||||
"start_asset_krw": 870154.0,
|
||||
"end_asset_krw": 1376957.0
|
||||
},
|
||||
{
|
||||
"month": "2025-10",
|
||||
"pnl_pct": 9.29,
|
||||
"start_asset_krw": 3515283.0,
|
||||
"end_asset_krw": 3842010.0
|
||||
"pnl_pct": 1.41,
|
||||
"start_asset_krw": 1376957.0,
|
||||
"end_asset_krw": 1396376.0
|
||||
},
|
||||
{
|
||||
"month": "2025-11",
|
||||
"pnl_pct": 11.24,
|
||||
"start_asset_krw": 3842010.0,
|
||||
"end_asset_krw": 4273771.0
|
||||
"pnl_pct": 19.55,
|
||||
"start_asset_krw": 1396376.0,
|
||||
"end_asset_krw": 1669367.0
|
||||
},
|
||||
{
|
||||
"month": "2025-12",
|
||||
"pnl_pct": -0.87,
|
||||
"start_asset_krw": 4273771.0,
|
||||
"end_asset_krw": 4236421.0
|
||||
"pnl_pct": -1.53,
|
||||
"start_asset_krw": 1669367.0,
|
||||
"end_asset_krw": 1643749.0
|
||||
},
|
||||
{
|
||||
"month": "2026-01",
|
||||
"pnl_pct": 33.77,
|
||||
"start_asset_krw": 4236421.0,
|
||||
"end_asset_krw": 5666889.0
|
||||
"pnl_pct": 28.68,
|
||||
"start_asset_krw": 1643749.0,
|
||||
"end_asset_krw": 2115197.0
|
||||
},
|
||||
{
|
||||
"month": "2026-02",
|
||||
"pnl_pct": 15.61,
|
||||
"start_asset_krw": 5666889.0,
|
||||
"end_asset_krw": 6551242.0
|
||||
"pnl_pct": 20.21,
|
||||
"start_asset_krw": 2115197.0,
|
||||
"end_asset_krw": 2542690.0
|
||||
},
|
||||
{
|
||||
"month": "2026-03",
|
||||
"pnl_pct": 9.12,
|
||||
"start_asset_krw": 6551242.0,
|
||||
"end_asset_krw": 7148390.0
|
||||
"pnl_pct": 9.7,
|
||||
"start_asset_krw": 2542690.0,
|
||||
"end_asset_krw": 2789334.0
|
||||
},
|
||||
{
|
||||
"month": "2026-04",
|
||||
"pnl_pct": 22.85,
|
||||
"start_asset_krw": 7148390.0,
|
||||
"end_asset_krw": 8782116.0
|
||||
"pnl_pct": 22.86,
|
||||
"start_asset_krw": 2789334.0,
|
||||
"end_asset_krw": 3426893.0
|
||||
},
|
||||
{
|
||||
"month": "2026-05",
|
||||
"pnl_pct": 47.57,
|
||||
"start_asset_krw": 8782116.0,
|
||||
"end_asset_krw": 12959660.0
|
||||
"pnl_pct": 44.66,
|
||||
"start_asset_krw": 3426893.0,
|
||||
"end_asset_krw": 4957419.0
|
||||
}
|
||||
],
|
||||
"walk_forward_summary": {
|
||||
@@ -292,29 +292,29 @@
|
||||
{
|
||||
"name": "hybrid_holdout_pnl",
|
||||
"pass": true,
|
||||
"value": 62.35
|
||||
"value": 59.15
|
||||
},
|
||||
{
|
||||
"name": "hybrid_max_mdd",
|
||||
"pass": true,
|
||||
"value": 19.22
|
||||
"value": 19.89
|
||||
},
|
||||
{
|
||||
"name": "hybrid_fee_stress_pnl",
|
||||
"pass": true,
|
||||
"value": 975.74
|
||||
"value": 947.42
|
||||
},
|
||||
{
|
||||
"name": "option_c_target_300pct",
|
||||
"pass": true,
|
||||
"value": 1147.3,
|
||||
"value": 1116.87,
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"go_no_go_option_c_phase2": {
|
||||
"go": true,
|
||||
"gt_capture_ratio": 0.2674,
|
||||
"go": false,
|
||||
"gt_capture_ratio": 0.1587,
|
||||
"targets": {
|
||||
"phase2_pnl_pct": 1000.0,
|
||||
"min_gt_capture": 0.23,
|
||||
@@ -328,33 +328,33 @@
|
||||
{
|
||||
"name": "full_pnl_1000pct",
|
||||
"pass": true,
|
||||
"value": 1147.3
|
||||
"value": 1116.87
|
||||
},
|
||||
{
|
||||
"name": "gt_capture_23pct",
|
||||
"pass": true,
|
||||
"value": 0.2674
|
||||
"pass": false,
|
||||
"value": 0.1587
|
||||
},
|
||||
{
|
||||
"name": "holdout_pnl_positive",
|
||||
"pass": true,
|
||||
"value": 62.35
|
||||
"value": 59.15
|
||||
},
|
||||
{
|
||||
"name": "max_mdd",
|
||||
"pass": true,
|
||||
"value": 19.22
|
||||
"value": 19.89
|
||||
},
|
||||
{
|
||||
"name": "fee_stress_ratio",
|
||||
"pass": true,
|
||||
"value": 975.74,
|
||||
"value": 947.42,
|
||||
"threshold": 850.0
|
||||
},
|
||||
{
|
||||
"name": "slippage_stress_positive",
|
||||
"pass": true,
|
||||
"value": 31.58,
|
||||
"value": 28.14,
|
||||
"note": "체결가 슬리피지 반영 후에도 흑자"
|
||||
},
|
||||
{
|
||||
@@ -366,87 +366,87 @@
|
||||
},
|
||||
"portfolio_compare": {
|
||||
"ground_truth_chrono": {
|
||||
"initial_cash_krw": 1000000,
|
||||
"final_asset_krw": 43913514.0,
|
||||
"pnl_krw": 42913514.0,
|
||||
"pnl_pct": 4291.35,
|
||||
"initial_cash_krw": 400000,
|
||||
"final_asset_krw": 28554960.0,
|
||||
"pnl_krw": 28154960.0,
|
||||
"pnl_pct": 7038.74,
|
||||
"total_fees_krw": 240578.0,
|
||||
"cash_krw": 43913514.0,
|
||||
"cash_krw": 28554960.0,
|
||||
"holding_qty": 0.0,
|
||||
"holding_value_krw": 0.0,
|
||||
"mark_price": 487.0,
|
||||
"fee_rate": 0.0005,
|
||||
"trade_count": 456,
|
||||
"max_drawdown_pct": 6.17,
|
||||
"peak_asset_krw": 1234838.0,
|
||||
"trough_asset_krw": 1158619.0
|
||||
"max_drawdown_pct": 8.34,
|
||||
"peak_asset_krw": 9032973.0,
|
||||
"trough_asset_krw": 8279398.0
|
||||
},
|
||||
"sim_sized": {
|
||||
"initial_cash_krw": 1000000,
|
||||
"final_asset_krw": 1746868.0,
|
||||
"pnl_krw": 746868.0,
|
||||
"pnl_pct": 74.69,
|
||||
"total_fees_krw": 29780.0,
|
||||
"cash_krw": 1509979.0,
|
||||
"holding_qty": 486.425998,
|
||||
"holding_value_krw": 236889.0,
|
||||
"initial_cash_krw": 400000,
|
||||
"final_asset_krw": 546248.0,
|
||||
"pnl_krw": 146248.0,
|
||||
"pnl_pct": 36.56,
|
||||
"total_fees_krw": 15161.0,
|
||||
"cash_krw": 467164.0,
|
||||
"holding_qty": 162.389581,
|
||||
"holding_value_krw": 79084.0,
|
||||
"mark_price": 487.0,
|
||||
"fee_rate": 0.0005,
|
||||
"trade_count": 5225,
|
||||
"max_drawdown_pct": 9.53,
|
||||
"peak_asset_krw": 1023021.0,
|
||||
"trough_asset_krw": 925549.0,
|
||||
"max_drawdown_pct": 20.54,
|
||||
"peak_asset_krw": 412326.0,
|
||||
"trough_asset_krw": 327644.0,
|
||||
"sizing_mode": "gt_model_compound_causal",
|
||||
"sizing_note": "전기간 복리·GT tier·총자산×비중, 보유현금 한도; 인과적 신호·tier(미래 미사용)"
|
||||
},
|
||||
"sim_fixed_order": {
|
||||
"initial_cash_krw": 1000000,
|
||||
"final_asset_krw": 58389.0,
|
||||
"pnl_krw": -941611.0,
|
||||
"initial_cash_krw": 400000,
|
||||
"final_asset_krw": 23356.0,
|
||||
"pnl_krw": -376644.0,
|
||||
"pnl_pct": -94.16,
|
||||
"total_fees_krw": 43869.0,
|
||||
"cash_krw": -0.0,
|
||||
"holding_qty": 119.896264,
|
||||
"holding_value_krw": 58389.0,
|
||||
"total_fees_krw": 17548.0,
|
||||
"cash_krw": 0.0,
|
||||
"holding_qty": 47.958506,
|
||||
"holding_value_krw": 23356.0,
|
||||
"mark_price": 487.0,
|
||||
"fee_rate": 0.0005,
|
||||
"order_krw": 100000.0,
|
||||
"order_krw": 40000.0,
|
||||
"sizing_mode": "fixed",
|
||||
"trade_count": 5225
|
||||
},
|
||||
"sim_gt_model": {
|
||||
"initial_cash_krw": 1000000,
|
||||
"final_asset_krw": 1081984.0,
|
||||
"pnl_krw": 81984.0,
|
||||
"pnl_pct": 8.2,
|
||||
"total_fees_krw": 240.0,
|
||||
"cash_krw": 881042.0,
|
||||
"holding_qty": 412.612808,
|
||||
"holding_value_krw": 200942.0,
|
||||
"initial_cash_krw": 400000,
|
||||
"final_asset_krw": 418244.0,
|
||||
"pnl_krw": 18244.0,
|
||||
"pnl_pct": 4.56,
|
||||
"total_fees_krw": 120.0,
|
||||
"cash_krw": 309487.0,
|
||||
"holding_qty": 223.320164,
|
||||
"holding_value_krw": 108757.0,
|
||||
"mark_price": 487.0,
|
||||
"fee_rate": 0.0005,
|
||||
"trade_count": 33,
|
||||
"max_drawdown_pct": 4.38,
|
||||
"peak_asset_krw": 1072413.0,
|
||||
"trough_asset_krw": 1025457.0,
|
||||
"max_drawdown_pct": 9.79,
|
||||
"peak_asset_krw": 429734.0,
|
||||
"trough_asset_krw": 387649.0,
|
||||
"sizing_mode": "gt_model_compound_causal",
|
||||
"sizing_note": "전기간 복리·GT tier·총자산×비중, 보유현금 한도; 인과적 신호·tier(미래 미사용)"
|
||||
},
|
||||
"sim_causal_gt": {
|
||||
"initial_cash_krw": 1000000,
|
||||
"final_asset_krw": 1147944.0,
|
||||
"pnl_krw": 147944.0,
|
||||
"pnl_pct": 14.79,
|
||||
"total_fees_krw": 2025.0,
|
||||
"cash_krw": 1147944.0,
|
||||
"initial_cash_krw": 400000,
|
||||
"final_asset_krw": 459948.0,
|
||||
"pnl_krw": 59948.0,
|
||||
"pnl_pct": 14.99,
|
||||
"total_fees_krw": 900.0,
|
||||
"cash_krw": 459948.0,
|
||||
"holding_qty": 0.0,
|
||||
"holding_value_krw": 0.0,
|
||||
"mark_price": 487.0,
|
||||
"fee_rate": 0.0005,
|
||||
"trade_count": 124,
|
||||
"max_drawdown_pct": 0.96,
|
||||
"peak_asset_krw": 1118535.0,
|
||||
"trough_asset_krw": 1107793.0,
|
||||
"trade_count": 129,
|
||||
"max_drawdown_pct": 1.51,
|
||||
"peak_asset_krw": 448991.0,
|
||||
"trough_asset_krw": 442216.0,
|
||||
"leg_count": 17,
|
||||
"sizing_mode": "causal_gt_leg_engine",
|
||||
"sizing_note": "인과 GT leg: split_buy + peak_sell, causal tier 복리 (미래 미사용)",
|
||||
@@ -461,46 +461,46 @@
|
||||
"use_local_trough": false
|
||||
},
|
||||
"alloc_stats": {
|
||||
"buy_executed": 91,
|
||||
"buy_executed": 96,
|
||||
"buy_skipped": 0,
|
||||
"sell_executed": 33,
|
||||
"sell_skipped": 0,
|
||||
"buy_total_krw": 1950020.0,
|
||||
"buy_total_krw": 870005.0,
|
||||
"large_leg_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_max_krw": 57216.0
|
||||
"buy_amount_max_krw": 22925.0
|
||||
}
|
||||
},
|
||||
"sim_causal_hybrid": {
|
||||
"initial_cash_krw": 1000000,
|
||||
"final_asset_krw": 12473032.0,
|
||||
"pnl_krw": 11473032.0,
|
||||
"pnl_pct": 1147.3,
|
||||
"total_fees_krw": 710297.0,
|
||||
"cash_krw": 0.0,
|
||||
"holding_qty": 25611.975633,
|
||||
"holding_value_krw": 12473032.0,
|
||||
"initial_cash_krw": 400000,
|
||||
"final_asset_krw": 4867465.0,
|
||||
"pnl_krw": 4467465.0,
|
||||
"pnl_pct": 1116.87,
|
||||
"total_fees_krw": 277183.0,
|
||||
"cash_krw": 3.0,
|
||||
"holding_qty": 9994.789459,
|
||||
"holding_value_krw": 4867462.0,
|
||||
"mark_price": 487.0,
|
||||
"fee_rate": 0.0005,
|
||||
"trade_count": 5225,
|
||||
"max_drawdown_pct": 19.22,
|
||||
"peak_asset_krw": 1174413.0,
|
||||
"trough_asset_krw": 948744.0,
|
||||
"max_drawdown_pct": 19.89,
|
||||
"peak_asset_krw": 3104956.0,
|
||||
"trough_asset_krw": 2487267.0,
|
||||
"sizing_mode": "monitor_dd_tier",
|
||||
"sizing_note": "monitor buy+sell + drawdown·past-leg tier (미래 미사용)",
|
||||
"alloc_stats": {
|
||||
"buy_executed": 1632,
|
||||
"buy_skipped": 655,
|
||||
"buy_executed": 1628,
|
||||
"buy_skipped": 659,
|
||||
"sell_executed": 2938,
|
||||
"sell_skipped": 0,
|
||||
"buy_total_krw": 710442254.0,
|
||||
"large_leg_count": 1535,
|
||||
"large_tier_buy_count": 1535,
|
||||
"buy_amount_avg_krw": 435320.0,
|
||||
"buy_amount_min_krw": 828.0,
|
||||
"buy_amount_max_krw": 8620153.0
|
||||
"buy_total_krw": 277244801.0,
|
||||
"large_leg_count": 1532,
|
||||
"large_tier_buy_count": 1532,
|
||||
"buy_amount_avg_krw": 170298.0,
|
||||
"buy_amount_min_krw": 323.0,
|
||||
"buy_amount_max_krw": 3363487.0
|
||||
},
|
||||
"input_fires": 5225
|
||||
},
|
||||
@@ -509,208 +509,208 @@
|
||||
"dd_medium_pct": 2.0
|
||||
},
|
||||
"sim_tier_enhanced": {
|
||||
"initial_cash_krw": 1000000,
|
||||
"final_asset_krw": 487437.0,
|
||||
"pnl_krw": -512563.0,
|
||||
"pnl_pct": -51.26,
|
||||
"total_fees_krw": 107068.0,
|
||||
"cash_krw": 1.0,
|
||||
"holding_qty": 1000.895401,
|
||||
"holding_value_krw": 487436.0,
|
||||
"initial_cash_krw": 400000,
|
||||
"final_asset_krw": 232667.0,
|
||||
"pnl_krw": -167333.0,
|
||||
"pnl_pct": -41.83,
|
||||
"total_fees_krw": 51034.0,
|
||||
"cash_krw": -0.0,
|
||||
"holding_qty": 477.75476,
|
||||
"holding_value_krw": 232667.0,
|
||||
"mark_price": 487.0,
|
||||
"fee_rate": 0.0005,
|
||||
"trade_count": 5225,
|
||||
"max_drawdown_pct": 74.08,
|
||||
"peak_asset_krw": 1540984.0,
|
||||
"trough_asset_krw": 399432.0,
|
||||
"max_drawdown_pct": 73.4,
|
||||
"peak_asset_krw": 735586.0,
|
||||
"trough_asset_krw": 195638.0,
|
||||
"sizing_mode": "monitor_tier_enhanced",
|
||||
"sizing_note": "monitor buy+sell + past-leg·drawdown tier + conviction (미래 미사용)",
|
||||
"alloc_stats": {
|
||||
"buy_executed": 279,
|
||||
"buy_skipped": 2008,
|
||||
"buy_executed": 241,
|
||||
"buy_skipped": 2046,
|
||||
"sell_executed": 2938,
|
||||
"sell_skipped": 0,
|
||||
"buy_total_krw": 107514607.0,
|
||||
"large_leg_count": 136,
|
||||
"large_tier_buy_count": 136,
|
||||
"buy_amount_avg_krw": 385357.0,
|
||||
"buy_total_krw": 51208940.0,
|
||||
"large_leg_count": 143,
|
||||
"large_tier_buy_count": 143,
|
||||
"buy_amount_avg_krw": 212485.0,
|
||||
"buy_amount_min_krw": 5000,
|
||||
"buy_amount_max_krw": 1540213.0
|
||||
"buy_amount_max_krw": 735224.0
|
||||
},
|
||||
"input_fires": 5225
|
||||
},
|
||||
"sim_sized_holdout": {
|
||||
"initial_asset_krw": 1605322.0,
|
||||
"final_asset_krw": 1751246.0,
|
||||
"pnl_krw": 145924.0,
|
||||
"pnl_pct": 9.09,
|
||||
"initial_asset_krw": 515736.0,
|
||||
"final_asset_krw": 547709.0,
|
||||
"pnl_krw": 31973.0,
|
||||
"pnl_pct": 6.2,
|
||||
"trade_count": 845,
|
||||
"note": "전기간 복리(causal tier) 후 holdout 구간 자산 증감"
|
||||
},
|
||||
"sim_hybrid_holdout": {
|
||||
"initial_asset_krw": 7982769.0,
|
||||
"final_asset_krw": 12959660.0,
|
||||
"pnl_krw": 4976891.0,
|
||||
"pnl_pct": 62.35,
|
||||
"initial_asset_krw": 3114931.0,
|
||||
"final_asset_krw": 4957419.0,
|
||||
"pnl_krw": 1842488.0,
|
||||
"pnl_pct": 59.15,
|
||||
"trade_count": 845,
|
||||
"note": "전기간 복리(hybrid DD tier) 후 holdout 구간 자산 증감"
|
||||
},
|
||||
"sim_hybrid_fee_stress": {
|
||||
"initial_cash_krw": 1000000,
|
||||
"final_asset_krw": 10757384.0,
|
||||
"pnl_krw": 9757384.0,
|
||||
"pnl_pct": 975.74,
|
||||
"total_fees_krw": 1289111.0,
|
||||
"initial_cash_krw": 400000,
|
||||
"final_asset_krw": 4189669.0,
|
||||
"pnl_krw": 3789669.0,
|
||||
"pnl_pct": 947.42,
|
||||
"total_fees_krw": 502923.0,
|
||||
"cash_krw": -0.0,
|
||||
"holding_qty": 22089.084224,
|
||||
"holding_value_krw": 10757384.0,
|
||||
"holding_qty": 8603.015894,
|
||||
"holding_value_krw": 4189669.0,
|
||||
"mark_price": 487.0,
|
||||
"fee_rate": 0.001,
|
||||
"trade_count": 5225,
|
||||
"max_drawdown_pct": 16.02,
|
||||
"peak_asset_krw": 7104706.0,
|
||||
"trough_asset_krw": 5966238.0
|
||||
"max_drawdown_pct": 20.18,
|
||||
"peak_asset_krw": 460724.0,
|
||||
"trough_asset_krw": 367741.0
|
||||
},
|
||||
"sim_hybrid_slippage_stress": {
|
||||
"initial_cash_krw": 1000000,
|
||||
"final_asset_krw": 1315755.0,
|
||||
"pnl_krw": 315755.0,
|
||||
"pnl_pct": 31.58,
|
||||
"total_fees_krw": 710297.0,
|
||||
"initial_cash_krw": 400000,
|
||||
"final_asset_krw": 512580.0,
|
||||
"pnl_krw": 112580.0,
|
||||
"pnl_pct": 28.14,
|
||||
"total_fees_krw": 277183.0,
|
||||
"cash_krw": -0.0,
|
||||
"holding_qty": 2701.755603,
|
||||
"holding_value_krw": 1315755.0,
|
||||
"holding_qty": 1052.52527,
|
||||
"holding_value_krw": 512580.0,
|
||||
"mark_price": 487.0,
|
||||
"fee_rate": 0.0005,
|
||||
"trade_count": 5225,
|
||||
"max_drawdown_pct": 59.08,
|
||||
"peak_asset_krw": 3483599.0,
|
||||
"trough_asset_krw": 1425625.0,
|
||||
"max_drawdown_pct": 59.13,
|
||||
"peak_asset_krw": 1358898.0,
|
||||
"trough_asset_krw": 555385.0,
|
||||
"slippage_pct": 0.05,
|
||||
"sizing_mode": "hybrid_slippage_stress"
|
||||
},
|
||||
"hybrid_portfolio_walk_forward": [
|
||||
{
|
||||
"month": "2025-06",
|
||||
"pnl_pct": 12.78,
|
||||
"start_asset_krw": 1000000.0,
|
||||
"end_asset_krw": 1127825.0
|
||||
"pnl_pct": 10.02,
|
||||
"start_asset_krw": 400000.0,
|
||||
"end_asset_krw": 440064.0
|
||||
},
|
||||
{
|
||||
"month": "2025-07",
|
||||
"pnl_pct": 60.89,
|
||||
"start_asset_krw": 1127825.0,
|
||||
"end_asset_krw": 1814584.0
|
||||
"start_asset_krw": 440064.0,
|
||||
"end_asset_krw": 708031.0
|
||||
},
|
||||
{
|
||||
"month": "2025-08",
|
||||
"pnl_pct": 22.9,
|
||||
"start_asset_krw": 1814584.0,
|
||||
"end_asset_krw": 2230083.0
|
||||
"start_asset_krw": 708031.0,
|
||||
"end_asset_krw": 870154.0
|
||||
},
|
||||
{
|
||||
"month": "2025-09",
|
||||
"pnl_pct": 57.63,
|
||||
"start_asset_krw": 2230083.0,
|
||||
"end_asset_krw": 3515283.0
|
||||
"pnl_pct": 58.24,
|
||||
"start_asset_krw": 870154.0,
|
||||
"end_asset_krw": 1376957.0
|
||||
},
|
||||
{
|
||||
"month": "2025-10",
|
||||
"pnl_pct": 9.29,
|
||||
"start_asset_krw": 3515283.0,
|
||||
"end_asset_krw": 3842010.0
|
||||
"pnl_pct": 1.41,
|
||||
"start_asset_krw": 1376957.0,
|
||||
"end_asset_krw": 1396376.0
|
||||
},
|
||||
{
|
||||
"month": "2025-11",
|
||||
"pnl_pct": 11.24,
|
||||
"start_asset_krw": 3842010.0,
|
||||
"end_asset_krw": 4273771.0
|
||||
"pnl_pct": 19.55,
|
||||
"start_asset_krw": 1396376.0,
|
||||
"end_asset_krw": 1669367.0
|
||||
},
|
||||
{
|
||||
"month": "2025-12",
|
||||
"pnl_pct": -0.87,
|
||||
"start_asset_krw": 4273771.0,
|
||||
"end_asset_krw": 4236421.0
|
||||
"pnl_pct": -1.53,
|
||||
"start_asset_krw": 1669367.0,
|
||||
"end_asset_krw": 1643749.0
|
||||
},
|
||||
{
|
||||
"month": "2026-01",
|
||||
"pnl_pct": 33.77,
|
||||
"start_asset_krw": 4236421.0,
|
||||
"end_asset_krw": 5666889.0
|
||||
"pnl_pct": 28.68,
|
||||
"start_asset_krw": 1643749.0,
|
||||
"end_asset_krw": 2115197.0
|
||||
},
|
||||
{
|
||||
"month": "2026-02",
|
||||
"pnl_pct": 15.61,
|
||||
"start_asset_krw": 5666889.0,
|
||||
"end_asset_krw": 6551242.0
|
||||
"pnl_pct": 20.21,
|
||||
"start_asset_krw": 2115197.0,
|
||||
"end_asset_krw": 2542690.0
|
||||
},
|
||||
{
|
||||
"month": "2026-03",
|
||||
"pnl_pct": 9.12,
|
||||
"start_asset_krw": 6551242.0,
|
||||
"end_asset_krw": 7148390.0
|
||||
"pnl_pct": 9.7,
|
||||
"start_asset_krw": 2542690.0,
|
||||
"end_asset_krw": 2789334.0
|
||||
},
|
||||
{
|
||||
"month": "2026-04",
|
||||
"pnl_pct": 22.85,
|
||||
"start_asset_krw": 7148390.0,
|
||||
"end_asset_krw": 8782116.0
|
||||
"pnl_pct": 22.86,
|
||||
"start_asset_krw": 2789334.0,
|
||||
"end_asset_krw": 3426893.0
|
||||
},
|
||||
{
|
||||
"month": "2026-05",
|
||||
"pnl_pct": 47.57,
|
||||
"start_asset_krw": 8782116.0,
|
||||
"end_asset_krw": 12959660.0
|
||||
"pnl_pct": 44.66,
|
||||
"start_asset_krw": 3426893.0,
|
||||
"end_asset_krw": 4957419.0
|
||||
}
|
||||
],
|
||||
"hybrid_portfolio_wf_summary": {
|
||||
"months": 12,
|
||||
"positive_months": 11,
|
||||
"positive_ratio": 0.9167,
|
||||
"mean_pnl_pct": 25.23
|
||||
"mean_pnl_pct": 24.8
|
||||
},
|
||||
"primary_sizing": "hybrid",
|
||||
"sim_primary": {
|
||||
"initial_cash_krw": 1000000,
|
||||
"final_asset_krw": 12473032.0,
|
||||
"pnl_krw": 11473032.0,
|
||||
"pnl_pct": 1147.3,
|
||||
"total_fees_krw": 710297.0,
|
||||
"cash_krw": 0.0,
|
||||
"holding_qty": 25611.975633,
|
||||
"holding_value_krw": 12473032.0,
|
||||
"initial_cash_krw": 400000,
|
||||
"final_asset_krw": 4867465.0,
|
||||
"pnl_krw": 4467465.0,
|
||||
"pnl_pct": 1116.87,
|
||||
"total_fees_krw": 277183.0,
|
||||
"cash_krw": 3.0,
|
||||
"holding_qty": 9994.789459,
|
||||
"holding_value_krw": 4867462.0,
|
||||
"mark_price": 487.0,
|
||||
"fee_rate": 0.0005,
|
||||
"trade_count": 5225,
|
||||
"max_drawdown_pct": 19.22,
|
||||
"peak_asset_krw": 1174413.0,
|
||||
"trough_asset_krw": 948744.0,
|
||||
"max_drawdown_pct": 19.89,
|
||||
"peak_asset_krw": 3104956.0,
|
||||
"trough_asset_krw": 2487267.0,
|
||||
"sizing_mode": "primary_hybrid_dd_tier",
|
||||
"sizing_note": "권장: monitor + past-leg·drawdown tier (검증 통과, 미래 미사용)",
|
||||
"alloc_stats": {
|
||||
"buy_executed": 1632,
|
||||
"buy_skipped": 655,
|
||||
"buy_executed": 1628,
|
||||
"buy_skipped": 659,
|
||||
"sell_executed": 2938,
|
||||
"sell_skipped": 0,
|
||||
"buy_total_krw": 710442254.0,
|
||||
"large_leg_count": 1535,
|
||||
"large_tier_buy_count": 1535,
|
||||
"buy_amount_avg_krw": 435320.0,
|
||||
"buy_amount_min_krw": 828.0,
|
||||
"buy_amount_max_krw": 8620153.0
|
||||
"buy_total_krw": 277244801.0,
|
||||
"large_leg_count": 1532,
|
||||
"large_tier_buy_count": 1532,
|
||||
"buy_amount_avg_krw": 170298.0,
|
||||
"buy_amount_min_krw": 323.0,
|
||||
"buy_amount_max_krw": 3363487.0
|
||||
},
|
||||
"input_fires": 5225
|
||||
},
|
||||
"gt_capture_ratio": 0.0174,
|
||||
"gt_pnl_pct": 4291.35,
|
||||
"sim_sized_pnl_pct": 74.69,
|
||||
"gt_model_capture_ratio": 0.0019,
|
||||
"causal_gt_capture_ratio": 0.0034,
|
||||
"sim_causal_gt_pnl_pct": 14.79,
|
||||
"causal_hybrid_capture_ratio": 0.2674,
|
||||
"sim_causal_hybrid_pnl_pct": 1147.3,
|
||||
"tier_enhanced_capture_ratio": -0.0119,
|
||||
"sim_tier_enhanced_pnl_pct": -51.26,
|
||||
"gt_capture_ratio": 0.0052,
|
||||
"gt_pnl_pct": 7038.74,
|
||||
"sim_sized_pnl_pct": 36.56,
|
||||
"gt_model_capture_ratio": 0.0006,
|
||||
"causal_gt_capture_ratio": 0.0021,
|
||||
"sim_causal_gt_pnl_pct": 14.99,
|
||||
"causal_hybrid_capture_ratio": 0.1587,
|
||||
"sim_causal_hybrid_pnl_pct": 1116.87,
|
||||
"tier_enhanced_capture_ratio": -0.0059,
|
||||
"sim_tier_enhanced_pnl_pct": -41.83,
|
||||
"causal_gt_params": {
|
||||
"peak_mode": "local",
|
||||
"pivot_order": 8,
|
||||
@@ -746,28 +746,28 @@
|
||||
"observed_implied_scale": {
|
||||
"all": {
|
||||
"count": 18,
|
||||
"mean": 0.1106,
|
||||
"median": 0.0527,
|
||||
"p25": 0.051,
|
||||
"p75": 0.054
|
||||
"mean": 0.3111,
|
||||
"median": 0.1346,
|
||||
"p25": 0.1309,
|
||||
"p75": 0.1509
|
||||
},
|
||||
"large_leg": {
|
||||
"count": 1,
|
||||
"mean": 1.0812,
|
||||
"median": 1.0812,
|
||||
"p25": 1.0812,
|
||||
"p75": 1.0812
|
||||
"mean": 3.2206,
|
||||
"median": 3.2206,
|
||||
"p25": 3.2206,
|
||||
"p75": 3.2206
|
||||
},
|
||||
"small_leg": {
|
||||
"count": 17,
|
||||
"mean": 0.0535,
|
||||
"median": 0.051,
|
||||
"p25": 0.051,
|
||||
"p75": 0.0539
|
||||
"mean": 0.14,
|
||||
"median": 0.1323,
|
||||
"p25": 0.1309,
|
||||
"p75": 0.1502
|
||||
}
|
||||
},
|
||||
"recommended_pct_large_leg": 1.0812,
|
||||
"recommended_pct_small_leg": 0.051,
|
||||
"recommended_pct_large_leg": 3.2206,
|
||||
"recommended_pct_small_leg": 0.1323,
|
||||
"note": "implied_scale = amount / (pre_buy_total_asset × weight_share); 시뮬 tier는 GT 분석 median 사용"
|
||||
},
|
||||
"causal_mode": {
|
||||
@@ -1499,15 +1499,15 @@
|
||||
],
|
||||
"gt_portfolio_calibration": {
|
||||
"portfolio": {
|
||||
"gt_final_asset_krw": 43913513.0,
|
||||
"subset_final_asset_krw": 1000000.0,
|
||||
"asset_ratio": 0.0228,
|
||||
"asset_accuracy_pct": 2.28,
|
||||
"gt_final_asset_krw": 28785435.0,
|
||||
"subset_final_asset_krw": 400000.0,
|
||||
"asset_ratio": 0.0139,
|
||||
"asset_accuracy_pct": 1.39,
|
||||
"target_met_90": false,
|
||||
"legs_total": 76,
|
||||
"legs_covered": 0,
|
||||
"leg_coverage_ratio": 0.0,
|
||||
"full_pnl_pct": 4291.35,
|
||||
"full_pnl_pct": 7096.36,
|
||||
"subset_pnl_pct": 0.0
|
||||
},
|
||||
"note": "캘리브레이션 미실행 — scripts/04_calibrate_gt_assets.py"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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** (소액 실거래)
|
||||
- **기준일:** 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
|
||||
# === Phase C: 알림만 (금~금) ===
|
||||
# === Phase C: dry-run (금~금, 시뮬 정합) ===
|
||||
LIVE_TRADING_ENABLED=0
|
||||
GT_SIGNAL_CAUSAL=1
|
||||
SIM_PRIMARY_SIZING=auto
|
||||
MONITOR_ALERT_COOLDOWN_MIN=180
|
||||
MONITOR_ALERT_COOLDOWN_MIN=3
|
||||
MONITOR_LOOP_SLEEP_SEC=180
|
||||
LIVE_DAILY_KRW_MAX=4000000
|
||||
LIVE_MAX_TRADES_PER_DAY=999
|
||||
LIVE_COOLDOWN_MIN=3
|
||||
```
|
||||
|
||||
### 4.2 매일 실행
|
||||
@@ -171,17 +174,17 @@ GT_SIGNAL_CAUSAL=1
|
||||
SIM_PRIMARY_SIZING=auto
|
||||
|
||||
# hybrid tier: large 구간 일부 체결 가능, sim보다 보수적
|
||||
LIVE_ORDER_KRW=100000
|
||||
LIVE_DAILY_KRW_MAX=1000000
|
||||
LIVE_ORDER_KRW=40000
|
||||
LIVE_DAILY_KRW_MAX=400000
|
||||
LIVE_MAX_TRADES_PER_DAY=15
|
||||
LIVE_COOLDOWN_MIN=180
|
||||
LIVE_DAILY_LOSS_LIMIT_KRW=100000
|
||||
LIVE_COOLDOWN_MIN=3
|
||||
LIVE_DAILY_LOSS_LIMIT_KRW=40000
|
||||
LIVE_SLIPPAGE_PCT=0.05
|
||||
```
|
||||
|
||||
| 변수 | 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_MAX_TRADES_PER_DAY` | 15 | sim 거래 빈도 대비 여유 |
|
||||
|
||||
|
||||
@@ -28,6 +28,15 @@ GT_BUY_PCT_SMALL_LEG=0.05
|
||||
GT_BUY_PCT_MEDIUM_LEG=0.25
|
||||
GT_LARGE_LEG_TOP_PCT=0.2
|
||||
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 변경 불필요)
|
||||
MATCH_MONITOR_MAX_PER_SIDE=1
|
||||
@@ -47,22 +56,36 @@ SIM_TIER_CONVICTION_DD_PCT=10.0
|
||||
# === Phase C ===
|
||||
LIVE_TRADING_ENABLED=0
|
||||
|
||||
# 05 알림
|
||||
MONITOR_ALERT_COOLDOWN_MIN=180
|
||||
MONITOR_ALERT_KRW_AMOUNT=100000
|
||||
# 05 알림 · 06 루프 — 3분봉 1주기(180초)
|
||||
MONITOR_ALERT_COOLDOWN_MIN=3
|
||||
MONITOR_ALERT_KRW_AMOUNT=40000
|
||||
MONITOR_LOOP_SLEEP_SEC=180
|
||||
|
||||
# 06 dry-run용 (C에서도 06 --once 가능, 주문 없음)
|
||||
LIVE_ORDER_KRW=100000
|
||||
LIVE_DAILY_KRW_MAX=300000
|
||||
LIVE_COOLDOWN_MIN=180
|
||||
LIVE_MAX_TRADES_PER_DAY=10
|
||||
LIVE_DAILY_LOSS_LIMIT_KRW=50000
|
||||
# 06 dry-run (시뮬 정합: 일한도·거래횟수 무제한, 쿨다운=봉간격 3분)
|
||||
# LIVE=0 + paper 모드 시 06은 _can_trade에서 LIVE 한도 검사 생략.
|
||||
LIVE_ORDER_KRW=40000
|
||||
LIVE_DAILY_KRW_MAX=4000000
|
||||
LIVE_COOLDOWN_MIN=3
|
||||
LIVE_MAX_TRADES_PER_DAY=999
|
||||
LIVE_DAILY_LOSS_LIMIT_KRW=20000
|
||||
LIVE_SLIPPAGE_PCT=0.05
|
||||
LIVE_BUY_PCT_LARGE=1.0
|
||||
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 확인 명령
|
||||
|
||||
```bash
|
||||
@@ -82,25 +105,25 @@ python scripts/06_execute_live.py --once
|
||||
# === Phase B-1 ===
|
||||
LIVE_TRADING_ENABLED=1
|
||||
|
||||
LIVE_ORDER_KRW=100000
|
||||
LIVE_DAILY_KRW_MAX=1000000
|
||||
LIVE_ORDER_KRW=40000
|
||||
LIVE_DAILY_KRW_MAX=400000
|
||||
LIVE_MAX_TRADES_PER_DAY=15
|
||||
LIVE_COOLDOWN_MIN=180
|
||||
LIVE_DAILY_LOSS_LIMIT_KRW=100000
|
||||
LIVE_COOLDOWN_MIN=3
|
||||
LIVE_DAILY_LOSS_LIMIT_KRW=40000
|
||||
LIVE_SLIPPAGE_PCT=0.05
|
||||
LIVE_BUY_PCT_LARGE=1.0
|
||||
LIVE_BUY_PCT_SMALL=0.05
|
||||
|
||||
MONITOR_ALERT_COOLDOWN_MIN=180
|
||||
MONITOR_ALERT_COOLDOWN_MIN=3
|
||||
MONITOR_LOOP_SLEEP_SEC=180
|
||||
```
|
||||
|
||||
| 항목 | 값 | 설명 |
|
||||
|------|-----|------|
|
||||
| 초기 자금 | ₩1,000,000 | 빗썸 가용 KRW |
|
||||
| 일 매수 한도 | ₩1,000,000 | large tier 일부 가능, sim(무한)보다 보수 |
|
||||
| 일 손실 중단 | ₩100,000 | -10% 일손실 시 당일 추가 주문 중단 |
|
||||
| 1회 참고 | ₩100,000 | 매도 시 참고; **매수는 hybrid tier가 산출** |
|
||||
| 초기 자금 | ₩400,000 | 빗썸 가용 KRW (`GT_INITIAL_CASH_KRW`) |
|
||||
| 일 매수 한도 | ₩400,000 | 초기 1배 — large tier 1회분 |
|
||||
| 일 손실 중단 | ₩40,000 | 초기 10% |
|
||||
| 1회 참고 | ₩40,000 | 알림·fallback; **매수는 hybrid tier** |
|
||||
|
||||
### B-1 오픈 당일
|
||||
|
||||
@@ -120,11 +143,11 @@ python scripts/06_execute_live.py
|
||||
# === Phase B-2 ===
|
||||
LIVE_TRADING_ENABLED=1
|
||||
|
||||
LIVE_ORDER_KRW=100000
|
||||
LIVE_DAILY_KRW_MAX=5000000
|
||||
LIVE_ORDER_KRW=40000
|
||||
LIVE_DAILY_KRW_MAX=2000000
|
||||
LIVE_MAX_TRADES_PER_DAY=30
|
||||
LIVE_COOLDOWN_MIN=120
|
||||
LIVE_DAILY_LOSS_LIMIT_KRW=300000
|
||||
LIVE_COOLDOWN_MIN=3
|
||||
LIVE_DAILY_LOSS_LIMIT_KRW=120000
|
||||
LIVE_SLIPPAGE_PCT=0.05
|
||||
LIVE_BUY_PCT_LARGE=1.0
|
||||
LIVE_BUY_PCT_SMALL=0.05
|
||||
@@ -132,7 +155,7 @@ LIVE_BUY_PCT_SMALL=0.05
|
||||
|
||||
| sim 대비 | B-1 | B-2 |
|
||||
|----------|-----|-----|
|
||||
| 일한도 | 100만 | 500만 |
|
||||
| 일한도 | 40만 | 200만 |
|
||||
| large tier 1회 ~100% | 종종 스킵 | 대부분 가능 |
|
||||
| +1000% 가능성 | 낮음 | sim에 근접 (보장 없음) |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Live Phase A — dry-run 검증
|
||||
|
||||
- 일시: 2026-06-01 23:17:30
|
||||
- 일시: 2026-06-01 23:36:56
|
||||
- 결과: **PASS**
|
||||
|
||||
## Plan (목적)
|
||||
@@ -25,53 +25,10 @@ python scripts/06_execute_live.py --once
|
||||
|
||||
## Act (다음 단계)
|
||||
|
||||
1. ~~`LIVE_TRADING_ENABLED=1`~~ **적용 완료 (Phase B-1, 2026-06-01)**
|
||||
2. `06_execute_live.py` 상시 루프 기동 (180초 주기)
|
||||
1. `05_run_monitor.py` 1~2일 병행 (알림만)
|
||||
2. `.env` 파일럿 한도 확정 후 `LIVE_TRADING_ENABLED=1`
|
||||
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
|
||||
|
||||
- `LIVE_TRADING_ENABLED=0` + 06 프로세스 중지
|
||||
|
||||
35
docs/05_ops/live_verification_20260602.md
Normal file
35
docs/05_ops/live_verification_20260602.md
Normal 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 프로세스 중지
|
||||
- 빗썸 앱 수동 청산
|
||||
39
docs/05_ops/live_verification_20260603.md
Normal file
39
docs/05_ops/live_verification_20260603.md
Normal 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 프로세스 중지
|
||||
- 빗썸 앱 수동 청산
|
||||
@@ -12,7 +12,7 @@ dry-run(Phase C)과 실거래(Phase B) 모두 **시뮬 `sim_primary` = `sim_caus
|
||||
| 배분 | hybrid DD tier + past-leg, **`enhanced=False`** |
|
||||
| 금지 | `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단계 전)
|
||||
|
||||
@@ -32,8 +32,9 @@ python scripts/05_run_monitor.py # 텔레그램 알림 (상시)
|
||||
python scripts/06_execute_live.py --once # dry_run 로그만 (선택)
|
||||
```
|
||||
|
||||
- 06은 발화 시 `dry_run (LIVE_TRADING_ENABLED=0)` 만 기록, **API 매수·매도 호출 없음**
|
||||
- 잔고 조회는 알림·tier 계산용으로 API를 쓸 수 있음
|
||||
- 06은 발화 시 **모의 계좌**(`paper_portfolio.json`)만 갱신, **빗썸 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
|
||||
|
||||
@@ -65,27 +66,34 @@ python scripts/06_execute_live.py # 상시
|
||||
| `LIVE_TRADING_ENABLED` | 0 | 1 | 1일 때만 실주문 |
|
||||
| `GT_SIGNAL_CAUSAL` | 1 | 1 | hybrid sizing |
|
||||
| `SIM_PRIMARY_SIZING` | auto | auto | primary=hybrid |
|
||||
| `LIVE_ORDER_KRW` | 100000 | 100000 | 매도·비-hybrid fallback 참고 |
|
||||
| `LIVE_DAILY_KRW_MAX` | 300000 | 1,000,000 | **sim 대비 체결 상한** |
|
||||
| `LIVE_COOLDOWN_MIN` | 180 | 180 | 규칙별 재주문 간격 |
|
||||
| `LIVE_MAX_TRADES_PER_DAY` | 10 | 10 | 일 최대 시도 |
|
||||
| `LIVE_DAILY_LOSS_LIMIT_KRW` | 50000 | 50000 | 일 손실 한도 |
|
||||
| `MONITOR_LOOP_SLEEP_SEC` | 180 | 180 | 05/06 루프 주기 |
|
||||
| `MONITOR_ALERT_COOLDOWN_MIN` | 180 | 180 | 텔레그램 중복 방지 |
|
||||
| `GT_INITIAL_CASH_KRW` | **400000** | **400000** | 시뮬·paper·hybrid 배분 시작 자금 |
|
||||
| `LIVE_ORDER_KRW` | **40000** | **40000** | 초기×10% · fallback 참고 |
|
||||
| `MONITOR_ALERT_KRW_AMOUNT` | **40000** | **40000** | 알림 참고(초기×10%) |
|
||||
| `LIVE_DAILY_KRW_MAX` | **4000000** (초기×10) | **400000** | C: hybrid 여유 · B-1: 1일 1배 |
|
||||
| `LIVE_DAILY_LOSS_LIMIT_KRW` | **20000** | **40000** | C: 초기×5% · B-1: ×10% |
|
||||
| `LIVE_COOLDOWN_MIN` | **3** (1봉) | **3** | `MATCH_PRIMARY_INTERVAL`과 동일 |
|
||||
| `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)
|
||||
|
||||
## 주문·배분 동작
|
||||
|
||||
- **매수:** EV/WF 통과 규칙만 hybrid tier 원화 산출 → `_can_trade` (일한도·쿨다운) → 시장가 매수
|
||||
- **매도:** 보유 WLD 전량 기준 시장가 매도 (`LIVE_ORDER_KRW`는 매도 경로에서 수량 우선)
|
||||
- **스킵:** 현금 부족, 일한도, hybrid planned > `LIVE_DAILY_KRW_MAX`, 미승인 규칙
|
||||
- **공통:** 발화 이력 → `size_monitor_signals` / `replay_paper_portfolio` (시뮬 `sim_causal_hybrid`와 동일)
|
||||
- **매수:** EV/WF 통과 규칙만 → `_can_trade` (live: 일한도·3분 쿨다운 / dry-run: 쿨다운만) → planned `amount_krw` 시장가 매수
|
||||
- **매도:** 시뮬 분할 `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 일별 기록 |
|
||||
|
||||
## 4단계 연결
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
| Phase | `LIVE_TRADING_ENABLED` | 일한도 예 | 비고 |
|
||||
|-------|------------------------|-----------|------|
|
||||
| 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 검증 후만 |
|
||||
|
||||
본인 자금·위험 성향에 맞게 **반드시** 낮춰 시작한다.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/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
|
||||
|
||||
@@ -11,13 +11,18 @@ runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
|
||||
|
||||
from config import ( # noqa: E402
|
||||
CHART_LOOKBACK_DAYS,
|
||||
GT_INITIAL_CASH_KRW,
|
||||
GT_SIGNAL_CAUSAL,
|
||||
LIVE_COOLDOWN_MIN,
|
||||
LIVE_DAILY_KRW_MAX,
|
||||
LIVE_DAILY_LOSS_LIMIT_KRW,
|
||||
LIVE_MAX_TRADES_PER_DAY,
|
||||
LIVE_ORDER_KRW,
|
||||
LIVE_TRADING_ENABLED,
|
||||
MATCH_LIVE_CACHE_SEC,
|
||||
MATCH_PRIMARY_INTERVAL,
|
||||
MONITOR_ALERT_KRW_AMOUNT,
|
||||
MONITOR_LOOP_SLEEP_SEC,
|
||||
SIM_PRIMARY_SIZING,
|
||||
SYMBOL,
|
||||
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.load_rules import load_monitor_rules
|
||||
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.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(
|
||||
@@ -87,6 +95,12 @@ def check_config() -> list[str]:
|
||||
f"loss_limit={LIVE_DAILY_LOSS_LIMIT_KRW:,} "
|
||||
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()
|
||||
print(f" monitor_rules={[r['rule_id'] for r in rules]}")
|
||||
if not GT_SIGNAL_CAUSAL:
|
||||
@@ -102,15 +116,57 @@ def check_config() -> list[str]:
|
||||
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]:
|
||||
"""hybrid vs conviction tier 금액 비교 (enhanced=False가 primary)."""
|
||||
issues: list[str] = []
|
||||
_print_header("2. hybrid tier 사이징 (시나리오)")
|
||||
price = 487.0
|
||||
ic = int(GT_INITIAL_CASH_KRW)
|
||||
scenarios = [
|
||||
("신규·소형DD(1%)", 1_000_000, 0.0, 1.0, {}),
|
||||
("신규·대형DD(6%)", 1_000_000, 0.0, 6.0, {}),
|
||||
("복리·과거large leg", 5_000_000, 2000.0, 3.0, {"completed_leg_ret": {1: 25.0}}),
|
||||
("신규·소형DD(1%)", ic, 0.0, 1.0, {}),
|
||||
("신규·대형DD(6%)", ic, 0.0, 6.0, {}),
|
||||
("복리·과거large leg", ic * 5, 2000.0, 3.0, {"completed_leg_ret": {1: 25.0}}),
|
||||
]
|
||||
for label, cash, qty, dd, extra in scenarios:
|
||||
hybrid_amt = _plan_with_dd(
|
||||
@@ -138,9 +194,35 @@ def check_tier_sizing(df) -> list[str]:
|
||||
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:
|
||||
"""시뮬 대비 실거래 일한도 영향 안내."""
|
||||
_print_header("3. 실거래 한도 vs hybrid tier")
|
||||
_print_header("4. 실거래 한도 vs hybrid tier")
|
||||
st = LivePositionState()
|
||||
mon = Monitor(cooldown_file=None)
|
||||
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(
|
||||
str(df.index[-1]) if df is not None and not df.empty else "2026-06-01 12:00:00",
|
||||
price,
|
||||
1_000_000,
|
||||
float(GT_INITIAL_CASH_KRW),
|
||||
0.0,
|
||||
df,
|
||||
enhanced=False,
|
||||
@@ -172,7 +254,7 @@ def check_live_limits() -> None:
|
||||
|
||||
def check_live_eval() -> None:
|
||||
"""현재 시점 규칙 발화."""
|
||||
_print_header("4. 현재 발화 (live_eval)")
|
||||
_print_header("5. 현재 발화 (live_eval)")
|
||||
fired = evaluate_live_rules(force_refresh=True)
|
||||
if not fired:
|
||||
print(" 발화 없음 (정상 — 신호 대기)")
|
||||
@@ -183,7 +265,7 @@ def check_live_eval() -> None:
|
||||
|
||||
def run_dryrun_once() -> None:
|
||||
"""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()
|
||||
|
||||
|
||||
@@ -199,7 +281,7 @@ def write_verification_report(issues: list[str], out_path: Path) -> None:
|
||||
"",
|
||||
"## Plan (목적)",
|
||||
"",
|
||||
"- hybrid primary(`enhanced=False`) live_trader 경로가 시뮬과 정합인지 확인",
|
||||
"- 06 dry-run/live 체결이 `hybrid_sim_execution`(sim_causal_hybrid)과 정합인지 확인",
|
||||
"- conviction tier(`enhanced=True`) 미사용 확인",
|
||||
"- 실거래 한도가 hybrid tier와 어떻게 상호작용하는지 기록",
|
||||
"",
|
||||
@@ -247,6 +329,7 @@ def main() -> int:
|
||||
"""Phase A 검증 실행."""
|
||||
print("[06_verify] Phase A dry-run 검증 시작")
|
||||
issues = check_config()
|
||||
issues.extend(check_capital_alignment())
|
||||
mon = Monitor(cooldown_file=None)
|
||||
frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
|
||||
df = frames.get(MATCH_PRIMARY_INTERVAL)
|
||||
@@ -254,6 +337,7 @@ def main() -> int:
|
||||
issues.append("3m OHLC 없음 — 01_download 필요")
|
||||
else:
|
||||
issues.extend(check_tier_sizing(df))
|
||||
issues.extend(check_paper_replay(df))
|
||||
check_live_limits()
|
||||
check_live_eval()
|
||||
run_dryrun_once()
|
||||
|
||||
@@ -10,6 +10,7 @@ Phase C dry-run 종료 후 모의 수익률(참고) 집계.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import runpy
|
||||
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.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
|
||||
|
||||
@@ -87,12 +93,22 @@ def attach_forward_returns(fires: pd.DataFrame, close_df: pd.DataFrame) -> pd.Da
|
||||
return fires
|
||||
|
||||
|
||||
def summarize(fires: pd.DataFrame) -> dict:
|
||||
"""집계 dict."""
|
||||
def summarize(fires: pd.DataFrame, *, report_kind: str = "daily") -> dict:
|
||||
"""
|
||||
집계 dict.
|
||||
|
||||
Args:
|
||||
fires: forward_ret_pct 포함 발화 DataFrame.
|
||||
report_kind: daily | final.
|
||||
|
||||
Returns:
|
||||
JSON 직렬화 가능 dict.
|
||||
"""
|
||||
traded = fires[fires["would_trade"] == True] # noqa: E712
|
||||
with_ret = traded[traded["forward_ret_pct"].notna()]
|
||||
out: dict = {
|
||||
"generated_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"report_kind": report_kind,
|
||||
"symbol": SYMBOL,
|
||||
"forward_bars": MATCH_FORWARD_BARS,
|
||||
"fee_round_trip_pct": _FEE_PCT,
|
||||
@@ -101,10 +117,17 @@ def summarize(fires: pd.DataFrame) -> dict:
|
||||
"skipped_count": int(len(fires) - len(traded)),
|
||||
"labeled_count": int(len(with_ret)),
|
||||
"note": (
|
||||
"모의 forward 수익률. 실계좌·hybrid 복리 PnL 아님. "
|
||||
"매수·매도 leg 미결합 단순 합산."
|
||||
"forward %는 발화별 참고 지표. "
|
||||
"총보유금액(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:
|
||||
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)
|
||||
@@ -125,13 +148,25 @@ def summarize(fires: pd.DataFrame) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""paper_fires 로드 → forward % → 리포트 저장."""
|
||||
fires = load_paper_fires(PAPER_FIRES_LOG)
|
||||
def build_phase_c_report(
|
||||
fires_path: Path | None = None,
|
||||
*,
|
||||
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:
|
||||
print(f"[07] 발화 로그 없음: {PAPER_FIRES_LOG}")
|
||||
print(" Phase C 기간 06_execute_live.py (LIVE=0) 상시 실행 후 재시도")
|
||||
return
|
||||
return {}, fires
|
||||
|
||||
mon = Monitor(cooldown_file=None)
|
||||
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"]))
|
||||
|
||||
fires = attach_forward_returns(fires, df)
|
||||
report = summarize(fires)
|
||||
PAPER_WEEKLY_REPORT_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||||
PAPER_WEEKLY_REPORT_JSON.write_text(
|
||||
report = summarize(fires, report_kind=report_kind)
|
||||
|
||||
mark = float(df["Close"].iloc[-1]) if not df.empty and "Close" in df.columns else 0.0
|
||||
paper = PaperPortfolio.load()
|
||||
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:
|
||||
lines.append(
|
||||
f"모의 forward 합산: {report['sum_forward_ret_pct']}% "
|
||||
f"(평균 {report['mean_forward_ret_pct']}%, "
|
||||
f"{report.get('forward_bars')}봉 후, 참고용)"
|
||||
)
|
||||
else:
|
||||
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")
|
||||
|
||||
print(f"[07] 저장: {PAPER_WEEKLY_REPORT_JSON}")
|
||||
print(f" 기간 로그: {fires['ts'].min()} ~ {fires['ts'].max()}")
|
||||
print(f" 발화 {report['total_signals']} · 체결가정(would_trade) {report['would_trade_count']}")
|
||||
if "sum_forward_ret_pct" in report:
|
||||
print(
|
||||
f" 모의 forward 합산: {report['sum_forward_ret_pct']}% "
|
||||
f"(평균 {report['mean_forward_ret_pct']}%, "
|
||||
f"{MATCH_FORWARD_BARS}봉 후, 참고용)"
|
||||
|
||||
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:
|
||||
print(" forward 라벨 가능 건 없음 (봉 데이터 부족 또는 발화 없음)")
|
||||
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__":
|
||||
|
||||
173
scripts/08_phase_c_supervisor.py
Normal file
173
scripts/08_phase_c_supervisor.py
Normal 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())
|
||||
22
scripts/run_phase_c_supervised.sh
Executable file
22
scripts/run_phase_c_supervised.sh
Executable 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"
|
||||
195
scripts/test_buy_sell_rehearsal.py
Normal file
195
scripts/test_buy_sell_rehearsal.py
Normal 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())
|
||||
Reference in New Issue
Block a user