Files
kis_trader/scripts/measure_ob_exit_early_fire.py

552 lines
20 KiB
Python

#!/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())