#!/usr/bin/env python3 """SCALP 2026-07-16 실매↔웹BT 진입 괴리 진단 (adhoc).""" from __future__ import annotations from collections import defaultdict from typing import Any, Dict, List, Optional, Tuple from database import TradeDB from kis_trader.backtest.scalping_backtest_common import resolve_scalp_universe from kis_trader.engine.scalping_engine import ( _apply_buy_state_filters, _eval_scalp_buy_at_index, get_scalping_defaults_from_db, ) def _z(code: Any) -> str: s = str(code or "").strip() return s.zfill(6) if s.isdigit() else s def _hm_from_buy(buy_date: str) -> Tuple[str, str]: """'2026-07-16 09:39:03' → day=20260716, entry_key≈202607160939""" raw = str(buy_date or "").strip().replace("-", "").replace(":", "").replace(" ", "") day = raw[:8] hm = raw[8:12] if len(raw) >= 12 else "" return day, day + hm def load_candles(db: TradeDB, code: str, day: str) -> List[Dict]: rows = db.conn.execute( """SELECT candle_time, open, high, low, close, volume, is_confirmed FROM ws_candles WHERE code=%s AND timeframe=1 AND candle_time LIKE %s ORDER BY candle_time""", (code, day + "%"), ).fetchall() out = [] for r in rows: d = dict(r) d["candle_time"] = str(d["candle_time"]) out.append(d) return out def find_index(candles: List[Dict], key12: str) -> Optional[int]: k = str(key12)[:12] for i, c in enumerate(candles): if str(c["candle_time"])[:12] == k: return i return None def nearest_index(candles: List[Dict], key12: str) -> Optional[int]: k = str(key12)[:12] best = None best_d = 10**9 for i, c in enumerate(candles): ct = str(c["candle_time"])[:12] if len(ct) < 12: continue try: d = abs(int(ct) - int(k)) except ValueError: continue if d < best_d: best_d = d best = i return best def eval_at(candles: List[Dict], i: int, params: Dict) -> Tuple[str, str]: if i is None or i < 1: return "no_idx", "봉인덱스 없음" state = {"daily_cnt": 0, "last_exit_dt": None} st = _apply_buy_state_filters(candles, i, params, state) if st[2] is None: return "state", str(st[0] or st[1] or "state_reject") rej, msg, sig = _eval_scalp_buy_at_index(candles, i, params) if rej: return str(rej), str(msg or "") if not sig: return "no_sig", "신호없음" rsi = sig.get("rsi") return "PASS", "rsi=%s mode=%s" % (rsi, sig.get("entry_mode")) def slot_has(univ: Dict[str, List[str]], slot: str, code: str) -> bool: if not univ: return False return code in (univ.get(slot) or []) def first_slots(univ: Dict[str, List[str]], code: str, day: str, limit: int = 8) -> List[str]: out = [] for sk in sorted(univ.keys()): if not sk.startswith(day): continue if code in (univ.get(sk) or []): out.append(sk) if len(out) >= limit: break return out def main() -> None: db = TradeDB() live_rows = db.conn.execute( """SELECT code, name, buy_date, sell_date, buy_price, sell_reason, realized_pnl FROM trade_history WHERE strategy=%s AND buy_date LIKE %s ORDER BY buy_date""", ("SCALP", "2026-07-16%"), ).fetchall() active_rows = db.conn.execute( """SELECT code, name, buy_date, avg_buy_price FROM active_trades WHERE strategy=%s AND buy_date LIKE %s""", ("SCALP", "2026-07-16%"), ).fetchall() # 사용자 웹 BT 9건 (스크린샷) bt_user = [ ("001130", "2026-07-16 14:55:00"), ("067830", "2026-07-16 14:25:00"), ("035000", "2026-07-16 09:55:00"), ("047770", "2026-07-16 14:27:00"), ("226400", "2026-07-16 12:36:00"), ("330350", "2026-07-16 12:42:00"), ("460930", "2026-07-16 09:21:00"), ("007540", "2026-07-16 09:36:00"), ("439090", "2026-07-16 09:52:00"), ] bt_codes = {_z(c) for c, _ in bt_user} live_codes = {_z(r["code"]) for r in list(live_rows) + list(active_rows)} params = get_scalping_defaults_from_db() params.update({ "rsi_period": 3, "rsi_oversold": 23.0, "rsi_overbought": 75.0, "sl_pct": 0.035, "tp_pct": 0.03, "tp_max_pct": 0.04, "drop_rate": 0.01, "vol_mult": 1.5, "use_defense_filters": False, "use_macd_cross": False, "skip_hts_scan_dupes": False, "require_reversal_candle": False, "min_price": 6000.0, "high_chase_thr": 0.99, "max_daily_chg": 50.0, "cooldown_min": 5, "time_start_hm": 900, "time_end_hm": 1530, "max_daily": 100, }) univ, usrc, nslots, _ = resolve_scalp_universe( "2026-07-16", "2026-07-16", use_saved_history=True, strategy_id="SCALP", ) univ = univ or {} print("UNIVERSE", usrc, "slots", nslots) print("\n" + "=" * 72) print("A) 실매 ONLY — 왜 BT가 못 샀나 (매수시각 기준 신호봉=진입직전봉)") print("=" * 72) for r in live_rows: code = _z(r["code"]) if code in bt_codes: tag = "BOTH" else: tag = "LIVE_ONLY" day, entry_key = _hm_from_buy(r["buy_date"]) candles = load_candles(db, code, day) # ALIGN: 진입봉=entry_key, 신호봉=직전 확정봉 entry_i = find_index(candles, entry_key) if entry_i is None: entry_i = nearest_index(candles, entry_key) signal_i = (entry_i - 1) if entry_i is not None and entry_i >= 1 else None slots = first_slots(univ, code, day, 5) in_entry_slot = slot_has(univ, entry_key, code) if entry_key else False sig_key = str(candles[signal_i]["candle_time"])[:12] if signal_i is not None else "" in_sig_slot = slot_has(univ, sig_key, code) if sig_key else False status, detail = ("no_candle", "분봉0") if signal_i is not None: status, detail = eval_at(candles, signal_i, params) # vol detail if reject vol_info = "" if signal_i is not None and candles: c = candles[signal_i] vols = [float(x.get("volume") or 0) for x in candles] win = max(1, min(20, signal_i)) avg = sum(vols[signal_i - win : signal_i]) / win if win else 0 vol = vols[signal_i] vol_info = "vol=%.0f avg20=%.0f need>=%.0f" % ( vol, avg, avg * float(params["vol_mult"]), ) print( f"\n[{tag}] {code} {r['name']} live_buy={r['buy_date']} @{r['buy_price']}" ) print(f" candles={len(candles)} entry_key={entry_key} signal_i={signal_i} sig_key={sig_key}") print(f" universe: in_entry_slot={in_entry_slot} in_sig_slot={in_sig_slot} first_slots={slots}") print(f" TRIGGER@signal: {status} | {detail} | {vol_info}") # also scan morning for first PASS in BT conditions first_pass = None for i in range(1, len(candles)): st, det = eval_at(candles, i, params) if st == "PASS": ck = str(candles[i]["candle_time"])[:12] if slot_has(univ, ck, code) or not univ: first_pass = (ck, det) break print(f" first PASS+univ day: {first_pass}") print("\n" + "=" * 72) print("B) BT ONLY — 실매는 왜 안 샀나 (BT 매수시각 기준)") print("=" * 72) for code, buy_t in bt_user: code = _z(code) if code in live_codes: continue day, entry_key = _hm_from_buy(buy_t) candles = load_candles(db, code, day) entry_i = find_index(candles, entry_key) signal_i = (entry_i - 1) if entry_i is not None and entry_i >= 1 else None sig_key = str(candles[signal_i]["candle_time"])[:12] if signal_i is not None else "" status, detail = eval_at(candles, signal_i, params) if signal_i is not None else ("no", "") slots = first_slots(univ, code, day, 5) # history presence count n_hist = db.conn.execute( "SELECT COUNT(*) n FROM target_candidates_history WHERE strategy_id=%s AND code=%s AND slot_key LIKE %s", ("SCALP", code, day + "%"), ).fetchone()["n"] print(f"\n[BT_ONLY] {code} bt_buy={buy_t}") print(f" hist_rows={n_hist} first_slots={slots}") print(f" TRIGGER@BT_signal {sig_key}: {status} | {detail}") print(f" in_sig_slot={slot_has(univ, sig_key, code)} in_entry_slot={slot_has(univ, entry_key, code)}") print("\n" + "=" * 72) print("C) BOTH — 시각 차이") print("=" * 72) live_by = {_z(r["code"]): r for r in live_rows} for code, buy_t in bt_user: code = _z(code) if code not in live_by: continue lr = live_by[code] print(f" {code}: LIVE {lr['buy_date']} @{lr['buy_price']} | BT {buy_t}") # vol_mult ON/OFF sensitivity for LIVE_ONLY print("\n" + "=" * 72) print("D) LIVE_ONLY — vol_mult=0 이면 PASS 되나?") print("=" * 72) p0 = dict(params) p0["vol_mult"] = 0.0 for r in live_rows: code = _z(r["code"]) if code in bt_codes: continue day, entry_key = _hm_from_buy(r["buy_date"]) candles = load_candles(db, code, day) entry_i = find_index(candles, entry_key) or nearest_index(candles, entry_key) signal_i = (entry_i - 1) if entry_i and entry_i >= 1 else None s1, d1 = eval_at(candles, signal_i, params) if signal_i is not None else ("?", "") s0, d0 = eval_at(candles, signal_i, p0) if signal_i is not None else ("?", "") print(f" {code}: vol1.5={s1}({d1[:40]}) | vol0={s0}({d0[:40]})") # min_price with defense OFF should not block — confirm print("\n" + "=" * 72) print("E) 슬롯 경쟁 가설 — 실매 매수 시각에 BT 후보가 몇 개?") print("=" * 72) # for each live buy minute, count how many codes PASS in that signal slot among univ for r in live_rows[:5]: day, entry_key = _hm_from_buy(r["buy_date"]) # signal approx entry-1min try: sig_num = int(entry_key) - 1 sig_key = str(sig_num) except Exception: sig_key = entry_key # fix minute borrow if entry_key.endswith("00"): # 0900 -> 0859 not valid; use find pass hh = int(entry_key[8:10]) mm = int(entry_key[10:12]) if mm == 0: hh -= 1 mm = 59 else: mm -= 1 sig_key = "%s%02d%02d" % (day, hh, mm) codes_in = list(univ.get(sig_key) or []) passes = [] for c in codes_in[:80]: candles = load_candles(db, _z(c), day) si = find_index(candles, sig_key) if si is None: continue st, det = eval_at(candles, si, params) if st == "PASS": passes.append((_z(c), det)) print( f" live={_z(r['code'])} @{r['buy_date']} sig={sig_key} " f"univ={len(codes_in)} PASS={len(passes)} sample={passes[:6]}" ) db.close() if __name__ == "__main__": main()