#!/usr/bin/env python3 """ measure_ob_exit_early_fire.py ============================= 초등 설명 이 숫자는 \"수익이 몇 %\"가 아닙니다. \"매수 100건 중, 진입 후 N봉 안에 호가컷 신호가 몇 건이나 뜨는가\" 비율입니다. 줄어든다고 한 것 = 수익이 줄어든다는 말이 아님. → \"진입 직후 호가 때문에 잘릴 뻔한 건수(가짜 청산 위험)\" 가 줄어든다는 뜻. 비교 3종 A) L1 + 순간 OR : 호가를 맨 위, 가드 없음 (위험 설계) B) L1 + 가드 : 호가를 맨 위지만 이익/최소보유/OR_MA 가드 C) L3 + 가드 : 래칫·어깨가 그 시각까지 안 잘랐을 때만 호가컷 (제안: 래칫→어깨→호가→손절) 사용 cd ~/kis_bot .venv/bin/python3 scripts/measure_ob_exit_early_fire.py .venv/bin/python3 scripts/measure_ob_exit_early_fire.py --date 2026-07-31 """ from __future__ import annotations import argparse import sys from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) def _parse_dt(v: Any) -> datetime: if isinstance(v, datetime): return v return datetime.strptime(str(v).strip()[:19], "%Y-%m-%d %H:%M:%S") def _snap_to_dt(snap_time: str) -> Optional[datetime]: s = str(snap_time or "").strip() if len(s) < 14: return None try: return datetime.strptime(s[:14], "%Y%m%d%H%M%S") except ValueError: return None def _candle_to_dt(candle_time: str) -> Optional[datetime]: s = str(candle_time or "").strip() if len(s) < 12: return None try: return datetime.strptime(s[:12], "%Y%m%d%H%M") except ValueError: return None def _or_ratio(bid: float, ask: float) -> Optional[float]: if ask <= 0: return None return float(bid) / float(ask) def _mid_px(best_bid: float, best_ask: float) -> Optional[float]: if best_bid > 0 and best_ask > 0: return (best_bid + best_ask) / 2.0 if best_ask > 0: return float(best_ask) if best_bid > 0: return float(best_bid) return None def _parse_ratchet_tiers(raw: str) -> List[Tuple[float, float]]: """'10:2.6,13:2.2' → [(0.10, 0.026), ...] (퍼센트 문자열 → 비율)""" tiers: List[Tuple[float, float]] = [] for chunk in str(raw or "").split(","): chunk = chunk.strip() if not chunk or ":" not in chunk: continue g, c = chunk.split(":", 1) try: gain = abs(float(g)) / 100.0 cut = abs(float(c)) / 100.0 except (TypeError, ValueError): continue if gain > 0 and cut > 0: tiers.append((gain, cut)) tiers.sort(key=lambda x: x[0]) return tiers def _rolling_or_ma(history: Sequence[Optional[float]], window: int) -> Optional[float]: vals = [x for x in history if x is not None] if len(vals) < window: return None chunk = vals[-window:] return sum(chunk) / float(window) @dataclass class Snap: t: datetime mid: Optional[float] or_ratio: Optional[float] best_bid: float = 0.0 best_ask: float = 0.0 @dataclass class Bar: t: datetime high: float low: float close: float @dataclass class RowOut: code: str buy_dt: datetime buy_price: float n_snaps: int a_l1_naive: bool b_l1_guard: bool c_l3_guard: bool price_exit_before_ob: str # '', ratchet, shoulder name: str = "" sell_price: float = 0.0 qty: int = 0 actual_pnl: float = 0.0 actual_profit_rate: float = 0.0 actual_sell_reason: str = "" c_exit_dt: Optional[datetime] = None c_exit_px: float = 0.0 entry_ob_pass: bool = True entry_reject_reason: str = "" def _load_buys(db, strategy: str, day: str) -> List[Dict[str, Any]]: return list( db.conn.execute( """ SELECT id, code, name, buy_date, buy_price, sell_price, qty, profit_rate, realized_pnl, sell_date, sell_reason FROM trade_history WHERE strategy=%s AND DATE(buy_date)=%s ORDER BY buy_date, id """, (strategy, day), ).fetchall() ) def _load_snaps(db, table: str, code: str, t0: datetime, t1: datetime) -> List[Snap]: rows = db.conn.execute( f""" SELECT snap_time, total_bid_qty, total_ask_qty, best_bid, best_ask FROM {table} WHERE code=%s AND snap_time >= %s AND snap_time < %s ORDER BY snap_time ASC, id ASC """, (code, t0.strftime("%Y%m%d%H%M%S"), t1.strftime("%Y%m%d%H%M%S")), ).fetchall() out: List[Snap] = [] for r in rows: dt = _snap_to_dt(r["snap_time"]) if not dt: continue bid = float(r["total_bid_qty"] or 0) ask = float(r["total_ask_qty"] or 0) out.append( Snap( t=dt, mid=_mid_px(float(r["best_bid"] or 0), float(r["best_ask"] or 0)), or_ratio=_or_ratio(bid, ask), best_bid=float(r["best_bid"] or 0), best_ask=float(r["best_ask"] or 0), ) ) return out def _load_bars(db, code: str, t0: datetime, t1: datetime) -> List[Bar]: """1분봉. candle_time=YYYYMMDDHHMM""" # LIKE 는 pymysql % 충돌 → %s 바인딩 prefix = t0.strftime("%Y%m%d") rows = db.conn.execute( """ SELECT candle_time, high, low, close FROM ws_candles WHERE code=%s AND timeframe=1 AND candle_time LIKE %s ORDER BY candle_time ASC """, (code, prefix + "%"), ).fetchall() out: List[Bar] = [] for r in rows: dt = _candle_to_dt(r["candle_time"]) if dt is None or dt < t0.replace(second=0, microsecond=0) or dt >= t1: continue out.append( Bar( t=dt, high=float(r["high"] or 0), low=float(r["low"] or 0), close=float(r["close"] or 0), ) ) return out def _price_exit_until( bars: List[Bar], buy_dt: datetime, entry: float, until: datetime, ratchet_tiers: List[Tuple[float, float]], shoulder_min_high: float, shoulder_cut: float, ) -> str: """until 시각까지 래칫/어깨가 먼저 걸리면 'ratchet'|'shoulder', 아니면 ''.""" max_px = entry for b in bars: if b.t > until: break if b.t < buy_dt.replace(second=0, microsecond=0): continue if b.high > max_px: max_px = b.high # 래칫 if ratchet_tiers and entry > 0: peak_gain = (max_px - entry) / entry cut_ratio = 0.0 for gain, cut in ratchet_tiers: if peak_gain >= gain: cut_ratio = cut if cut_ratio > 0 and b.low <= max_px * (1.0 - cut_ratio): return "ratchet" # 어깨 if entry > 0 and max_px >= entry * (1.0 + shoulder_min_high) and shoulder_cut > 0: if b.low <= max_px * (1.0 - shoulder_cut): return "shoulder" return "" def evaluate_one( snaps: List[Snap], bars: List[Bar], buy_dt: datetime, buy_price: float, *, n_bars: int, bar_minutes: int, min_hold_bars: int, ob_ratio_min: float, min_profit_pct: float, ma_window: int, ratchet_tiers: List[Tuple[float, float]], shoulder_min_high: float, shoulder_cut: float, ) -> Tuple[bool, bool, bool, str, int, Optional[datetime], float, bool, str]: window_end = buy_dt + timedelta(minutes=n_bars * bar_minutes) min_hold_end = buy_dt + timedelta(minutes=min_hold_bars * bar_minutes) profit_line = buy_price * (1.0 + min_profit_pct) in_window = [s for s in snaps if buy_dt <= s.t < window_end] n_snaps = len(in_window) if n_snaps <= 0: return False, False, False, "", 0, None, 0.0, True, "" # 진입 호가필터 (스프레드 상한 0.45%, 매수/매도 잔량비 하한 0.85 기본 적용) entry_ob_pass = True entry_reject_reason = "" close_snaps = [s for s in snaps if abs((s.t - buy_dt).total_seconds()) <= 60.0] if close_snaps: s0 = min(close_snaps, key=lambda s: abs((s.t - buy_dt).total_seconds())) if s0.mid and s0.mid > 0 and s0.best_ask > s0.best_bid > 0: spread_pct = (s0.best_ask - s0.best_bid) / s0.mid * 100.0 if spread_pct > 0.45: entry_ob_pass = False entry_reject_reason = f"스프레드({spread_pct:.2f}%)" if entry_ob_pass and s0.or_ratio is not None and s0.or_ratio < 0.85: entry_ob_pass = False entry_reject_reason = f"잔량비({s0.or_ratio:.2f}<0.85)" hist_or: List[Optional[float]] = [s.or_ratio for s in snaps if s.t < buy_dt] a_fire = b_fire = c_fire = False price_tag = "" c_exit_dt = None c_exit_px = 0.0 for s in in_window: hist_or.append(s.or_ratio) if s.or_ratio is None: continue # A) L1 순간 OR — 가드 없음 if (not a_fire) and s.or_ratio < ob_ratio_min: a_fire = True # 가드 공통 guard_ok = ( s.t >= min_hold_end and s.mid is not None and s.mid >= profit_line ) or_ma = _rolling_or_ma(hist_or, ma_window) guard_ob = guard_ok and or_ma is not None and or_ma < ob_ratio_min # B) L1 + 가드 (호가 맨 위라 가격레이어 무시) if (not b_fire) and guard_ob: b_fire = True # C) L3 + 가드: 그 시각까지 래칫/어깨 미발동일 때만 if (not c_fire) and guard_ob: pe = _price_exit_until( bars, buy_dt, buy_price, s.t, ratchet_tiers, shoulder_min_high, shoulder_cut, ) if pe: if not price_tag: price_tag = pe else: c_fire = True c_exit_dt = s.t c_exit_px = s.best_bid if s.best_bid > 0 else (s.mid or 0.0) return a_fire, b_fire, c_fire, price_tag, n_snaps, c_exit_dt, c_exit_px, entry_ob_pass, entry_reject_reason def _pct(n: int, d: int) -> str: if d <= 0: return "n/a" return f"{100.0 * n / d:.1f}%" def _load_momentum_exit_params(db) -> Tuple[List[Tuple[float, float]], float, float, str]: """config_momentum 실매값. 어깨는 DB가 비율(0.05)로 들어 있는 현황 반영.""" row = db.conn.execute( "SELECT MOMENTUM_RATCHET_TIERS, MOMENTUM_SHOULDER_MIN_HIGH_PCT, MOMENTUM_SHOULDER_CUT_PCT " "FROM config_momentum ORDER BY id DESC LIMIT 1" ).fetchone() d = dict(row) if row else {} tiers_s = str(d.get("MOMENTUM_RATCHET_TIERS") or "10:2.6,13:2.2") smh = float(d.get("MOMENTUM_SHOULDER_MIN_HIGH_PCT") or 0.05) sc = float(d.get("MOMENTUM_SHOULDER_CUT_PCT") or 0.0055) # 실수: 5 같이 들어오면 퍼센트로 보고 /100 if smh > 1.0: smh = smh / 100.0 if sc > 1.0: sc = sc / 100.0 return _parse_ratchet_tiers(tiers_s), smh, sc, tiers_s def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--date", default="2026-07-27") ap.add_argument("--strategy", default="MOMENTUM") ap.add_argument("--n-bars", type=int, default=5) ap.add_argument("--bar-minutes", type=int, default=1) ap.add_argument("--min-hold-bars", type=int, default=3) ap.add_argument("--ob-ratio-min", type=float, default=0.4) ap.add_argument("--min-profit-pct", type=float, default=0.005) ap.add_argument("--ma-window", type=int, default=5) ap.add_argument("--ob-table", default="ws_orderbook", choices=("ws_orderbook", "ls_ws_orderbook")) ap.add_argument("--verbose", action="store_true") args = ap.parse_args() from database import TradeDB db = TradeDB() cols = [r["Field"] for r in db.conn.execute(f"SHOW COLUMNS FROM {args.ob_table}").fetchall()] need = {"code", "snap_time", "total_bid_qty", "total_ask_qty", "best_bid", "best_ask"} if need - set(cols): print("컬럼 부족", need - set(cols)) return 1 tiers, smh, sc, tiers_s = _load_momentum_exit_params(db) buys = _load_buys(db, args.strategy, args.date) if not buys: print("매수 0건") return 0 lookback = timedelta(minutes=max(args.ma_window, 5) * args.bar_minutes) horizon = timedelta(minutes=args.n_bars * args.bar_minutes) rows: List[RowOut] = [] for b in buys: buy_dt = _parse_dt(b["buy_date"]) buy_px = float(b["buy_price"] or 0) code = str(b["code"]) snaps = _load_snaps(db, args.ob_table, code, buy_dt - lookback, buy_dt + horizon) bars = _load_bars(db, code, buy_dt, buy_dt + horizon + timedelta(minutes=1)) a, bb, c, ptag, n_snaps, c_dt, c_px, ep_pass, ep_reason = evaluate_one( snaps, bars, buy_dt, buy_px, n_bars=args.n_bars, bar_minutes=args.bar_minutes, min_hold_bars=args.min_hold_bars, ob_ratio_min=args.ob_ratio_min, min_profit_pct=args.min_profit_pct, ma_window=args.ma_window, ratchet_tiers=tiers, shoulder_min_high=smh, shoulder_cut=sc, ) rows.append( RowOut( code=code, buy_dt=buy_dt, buy_price=buy_px, n_snaps=n_snaps, a_l1_naive=a, b_l1_guard=bb, c_l3_guard=c, price_exit_before_ob=ptag, name=str(b.get("name") or ""), sell_price=float(b.get("sell_price") or 0), qty=int(b.get("qty") or 0), actual_pnl=float(b.get("realized_pnl") or 0), actual_profit_rate=float(b.get("profit_rate") or 0), actual_sell_reason=str(b.get("sell_reason") or ""), c_exit_dt=c_dt, c_exit_px=c_px, entry_ob_pass=ep_pass, entry_reject_reason=ep_reason, ) ) meas = [r for r in rows if r.n_snaps > 0] na = sum(1 for r in meas if r.a_l1_naive) nb = sum(1 for r in meas if r.b_l1_guard) nc = sum(1 for r in meas if r.c_l3_guard) n_price = sum(1 for r in meas if r.price_exit_before_ob) print() print("=" * 70) print("이 퍼센트는 수익이 아닙니다") print(" = (진입 후 N봉 안에 '호가컷 신호'가 뜬 매수 건수) / (호가 있는 매수 건수)") print(" '확 줄어든다' = 그 가짜·조기 호가청산 신호가 줄어든다 (수익%% 아님)") print("=" * 70) print(f"날짜 {args.date} 전략 {args.strategy} 관찰=진입후 {args.n_bars}분") print(f"호가테이블 {args.ob_table}") print( f"가드: 최소보유 {args.min_hold_bars}분 + 이익≥{args.min_profit_pct*100:.2f}% " f"+ OR이동평균{args.ma_window}개 < {args.ob_ratio_min}" ) print(f"L3용 가격청산(실매 config): ratchet={tiers_s!r} shoulder_min={smh} cut={sc}") print("-" * 70) print(f"매수 {len(rows)}건 중 호가데이터 있는 모수 {len(meas)}건") print() print(f" A) 호가 1순위 + 가드없음(순간OR) : {na}/{len(meas)} = {_pct(na, len(meas))}") print(f" → 맨 위에 올리면, 5봉 안에 호가신호로 잘릴 뻔한 비율") print() print(f" B) 호가 1순위 + 가드있음 : {nb}/{len(meas)} = {_pct(nb, len(meas))}") print(f" → 그래도 1순위. 가드만 켠 것 (순서 변경 아님)") print() print(f" C) 호가 3순위 + 가드있음 : {nc}/{len(meas)} = {_pct(nc, len(meas))}") print(f" → 래칫·어깨가 먼저 안 잘랐을 때만 호가컷 카운트") if n_price: print(f" (같은 창에서 래칫/어깨가 먼저 보인 건수: {n_price})") print("-" * 70) print("한줄 해석") print(f" A→B : 가드 효과로 조기호가신호 {_pct(na, len(meas))} → {_pct(nb, len(meas))}") print(f" B→C : 3순위로 내리면 {_pct(nb, len(meas))} → {_pct(nc, len(meas))}") if nb == nc: print(" ※ B≈C 이면: 이 N봉 안에 래칫/어깨가 거의 안 걸림 → 순서보다 가드가 핵심") print("=" * 70) print("\n건별 [A순간 / B가드1순위 / C가드3순위 / 진입필터 & 사후손익]") for r in rows: if r.n_snaps <= 0: print(f" {r.buy_dt.strftime('%H:%M:%S')} {r.code} snaps=0 (호가없음) | 실현손익 {r.actual_pnl:,.0f}원 ({r.actual_profit_rate:+.2f}%)") continue ef_str = "OK" if r.entry_ob_pass else f"탈락:{r.entry_reject_reason}" ob_exit_str = f" → 호가컷({r.c_exit_px:,.0f}원, {r.c_exit_dt.strftime('%H:%M:%S')})" if (r.c_l3_guard and r.c_exit_dt) else "" print( f" {r.buy_dt.strftime('%H:%M:%S')} {r.code} ({r.name[:6]:<6}) snaps={r.n_snaps:3d} " f"A={'Y' if r.a_l1_naive else '.'} " f"B={'Y' if r.b_l1_guard else '.'} " f"C={'Y' if r.c_l3_guard else '.'}" + (f" (가격먼저:{r.price_exit_before_ob})" if r.price_exit_before_ob else "") + f" | [진입] {ef_str:<12} | [실제] {r.actual_pnl:>10,.0f}원 ({r.actual_profit_rate:>+6.2f}%){ob_exit_str}" ) # ── 추가: 실매매 vs 호가 가드(C) / 호가필터 적용 시 손익·승률 사후 비교 ── print() print("=" * 72) print("💰 [사후 실현손익 및 승률 비교] 호가필터 & 호가청산(C) 적용 시 예상 효과") print("=" * 72) print(f" {'구분':<18} {'거래수':>5} {'승률':>8} {'총 실현손익':>15} {'차액(vs원본)':>13} {'평균수익률':>10}") print("-" * 72) def _calc_metrics(tr_list: List[RowOut], use_ob_exit: bool) -> Tuple[int, float, float, float]: if not tr_list: return 0, 0.0, 0.0, 0.0 tot_pnl = 0.0 tot_rate = 0.0 win_cnt = 0 for tr in tr_list: if use_ob_exit and tr.c_l3_guard and tr.c_exit_px > 0 and tr.buy_price > 0 and tr.qty > 0: # 0.23% 세금/수수료 기본 반영 ratio = (tr.c_exit_px / tr.buy_price) - 1.0 - 0.0023 rate = ratio * 100.0 pnl = float(round(ratio * tr.buy_price * tr.qty)) else: rate = tr.actual_profit_rate pnl = tr.actual_pnl tot_pnl += pnl tot_rate += rate if pnl > 0: win_cnt += 1 cnt = len(tr_list) return cnt, (win_cnt / cnt * 100.0), tot_pnl, (tot_rate / cnt) # [0] 원본 실매매 c0, w0, p0, r0 = _calc_metrics(rows, use_ob_exit=False) print(f" [원본] 실제 매매 {c0:4d}건 {w0:7.1f}% {p0:14,.0f}원 {'-':>13} {r0:9.2f}%") # [1] ① 청산 호가매도 ON c1, w1, p1, r1 = _calc_metrics(rows, use_ob_exit=True) print(f" [①] 청산 호가매도(C) ON {c1:4d}건 {w1:7.1f}% {p1:14,.0f}원 {p1-p0:+12,.0f}원 {r1:9.2f}%") # [2] ② 진입 호가필터 ON rows_filt = [r for r in rows if r.entry_ob_pass] c2, w2, p2, r2 = _calc_metrics(rows_filt, use_ob_exit=False) print(f" [②] 진입 호가필터 ON {c2:4d}건 {w2:7.1f}% {p2:14,.0f}원 {p2-p0:+12,.0f}원 {r2:9.2f}%") # [3] ③ 진입+청산 동시 ON c3, w3, p3, r3 = _calc_metrics(rows_filt, use_ob_exit=True) print(f" [③] 진입+청산 동시 ON {c3:4d}건 {w3:7.1f}% {p3:14,.0f}원 {p3-p0:+12,.0f}원 {r3:9.2f}%") print("=" * 72) return 0 if __name__ == "__main__": raise SystemExit(main())