""" 4/30 SHORT/SCALP 백테스트 진단 스크립트. 목적: 1) SHORT 0건의 진짜 원인 — 어느 임계치에서 거래가 발생하는지 sweep 2) SCALP -90만 trades 의 종목·시각·매매 상세 """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from database import TradeDB import tail_engine as te import scalping_engine as se START = "202604300830" END = "202604301600" def _load_universe(db, sid: str) -> dict: """target_candidates_history → {slot_key: [codes]}.""" rows = db.conn.execute( "SELECT slot_key, code FROM target_candidates_history " "WHERE strategy_id=%s AND DATE(event_time)='2026-04-30'", (sid,), ).fetchall() history: dict = {} for r in rows: history.setdefault(r["slot_key"], []).append(r["code"]) return history def _load_candles(db, codes, tf: int, min_n: int = 19) -> dict: out = {} for code in codes: rows = db.conn.execute( "SELECT candle_time, open, high, low, close, volume FROM ws_candles " "WHERE timeframe=%s AND code=%s AND candle_time>=%s AND candle_time<=%s " "AND is_confirmed=1 ORDER BY candle_time", (tf, code, START, END), ).fetchall() if len(rows) >= min_n: out[code] = [dict(r) for r in rows] return out def _trade_pnl(t): """엔진별 trade dict 차이를 흡수해 pnl 추출.""" if t.get("pnl") is not None: return float(t["pnl"]) try: entry = float(t.get("entry") or t.get("buy_price") or 0) exit_ = float(t.get("exit") or t.get("sell_price") or 0) qty = int(t.get("qty") or 1) return (exit_ - entry) * qty except Exception: return 0.0 def _summary(trades): if not trades: return "trades=0" n = len(trades) pnls = [_trade_pnl(t) for t in trades] win = sum(1 for p in pnls if p > 0) return (f"trades={n} pnl합={sum(pnls):,.0f}원 " f"승률={win}/{n}={win/n*100:.0f}% avg={sum(pnls)/n:,.0f}원") def diag_short(db): print("\n" + "=" * 70) print("SHORT (꼬리잡기) 4/30 진단") print("=" * 70) history = _load_universe(db, "SHORT") codes = {c for lst in history.values() for c in lst} print(f"universe: {len(codes)}종목, {len(history)}슬롯") candles = _load_candles(db, codes, tf=3, min_n=19) print(f"3분봉 적재: {len(candles)}종목 (RSI 계산 가능)") base = te.get_tail_defaults_from_db() base["scan_interval_min"] = 1 # SHORT slot 1분 단위 print() print("--- 현재 DB 임계치 ---") print(f" min_drop_rate = {base['min_drop_rate']*100:.2f}%") print(f" min_recovery_ratio = {base['min_recovery_ratio']*100:.0f}%") print(f" tail_ratio_min = {base['tail_ratio_min']:.2f}") print(f" tail_pct_min = {base['tail_pct_min']*100:.2f}%") print(f" max_daily_change = {base['max_daily_change']:.1f}%") print(f" ma20_max_above = {base['ma20_max_above']:.1f}%") print(f" rsi_threshold = {base['rsi_threshold']:.1f}") print(f" max_rec_3m = {base['max_rec_3m']*100:.0f}%") print(f" high_chase_thr = {base['high_chase_thr']:.2f}") print() print("--- 현재 임계치로 백테스트 ---") trades = te.run_tail_backtest(candles, dict(base), universe_by_slot=history) print(f" → {_summary(trades)}") print() print("--- 임계치 단계적 완화 sweep ---") sweeps = [ ("baseline (현재)", {}), ("MIN_DROP 1.5% / REC 25%", {"min_drop_rate": 0.015, "min_recovery_ratio": 0.25}), ("MIN_DROP 1.0% / REC 20%", {"min_drop_rate": 0.010, "min_recovery_ratio": 0.20}), ("MIN_DROP 0.5% / REC 10%", {"min_drop_rate": 0.005, "min_recovery_ratio": 0.10}), ("MIN_DROP 0% / REC 0%", {"min_drop_rate": 0.0, "min_recovery_ratio": 0.0}), ("MA20 ∞ / RSI ∞ + 위 같음", { "min_drop_rate": 0.0, "min_recovery_ratio": 0.0, "ma20_max_above": 999.0, "rsi_threshold": 999.0, "max_daily_change": 999.0, "max_rec_3m": 1.0, "high_chase_thr": 999.0, "tail_ratio_min": 0.0, "tail_pct_min": 0.0, }), ] for name, ov in sweeps: p = dict(base) p.update(ov) try: t = te.run_tail_backtest(candles, p, universe_by_slot=history) print(f" {name:35s} → {_summary(t)}") except Exception as e: print(f" {name:35s} → 예외: {e}") def diag_scalp(db): print("\n" + "=" * 70) print("SCALP 4/30 진단") print("=" * 70) history = _load_universe(db, "SCALP") codes = {c for lst in history.values() for c in lst} print(f"universe: {len(codes)}종목, {len(history)}슬롯") # SCALP universe slot 의 분 단위 분포 (5분 정렬 매칭률 보기) from collections import Counter mins = Counter(int(k[10:12]) for k in history.keys() if len(k) >= 12) aligned5 = sum(c for m, c in mins.items() if m % 5 == 0) total = sum(mins.values()) print(f" slot 5분 정렬 매칭률 (backtest_web 의 scan_interval_min=5 와 매칭): " f"{aligned5}/{total} = {aligned5/total*100:.0f}%" if total else "") candles = _load_candles(db, codes, tf=1, min_n=10) print(f"1분봉 적재: {len(candles)}종목") base = se.get_scalping_defaults_from_db() # backtest_web SCALP 와 동일하게 scan_interval_min=5 로 1차 시도 base["scan_interval_min"] = 5 print() print("--- backtest_web 과 동일 (scan_interval_min=5) ---") trades5 = se.run_scalping_backtest(candles, dict(base), universe_by_slot=history) print(f" → {_summary(trades5)}") # 1분 슬롯과 1:1 매칭 (universe history 1분 키와 일치) base["scan_interval_min"] = 1 print() print("--- 보정: scan_interval_min=1 (universe slot 키와 일치) ---") trades1 = se.run_scalping_backtest(candles, dict(base), universe_by_slot=history) print(f" → {_summary(trades1)}") trades = trades1 if trades1 else trades5 if trades: print() print(f"--- trades 상세 (총 {len(trades)}건) ---") for t in trades: t["pnl"] = _trade_pnl(t) trades.sort(key=lambda x: x["pnl"]) print(" ▼ 손실 큰 5건:") for t in trades[:5]: print(f" {t.get('code')} {t.get('buy_time')}→{t.get('sell_time')} " f"매수 {t.get('buy_price'):.0f} → 매도 {t.get('sell_price'):.0f} " f"×{t.get('qty')} pnl={t['pnl']:,.0f} ({t.get('sell_reason')})") print(" ▲ 수익 큰 5건:") for t in trades[-5:][::-1]: print(f" {t.get('code')} {t.get('buy_time')}→{t.get('sell_time')} " f"매수 {t.get('buy_price'):.0f} → 매도 {t.get('sell_price'):.0f} " f"×{t.get('qty')} pnl={t['pnl']:,.0f} ({t.get('sell_reason')})") cnt = Counter(t.get("sell_reason") for t in trades) print(f" sell_reason 분포: {dict(cnt)}") def main(): db = TradeDB() try: diag_short(db) diag_scalp(db) finally: db.close() if __name__ == "__main__": main()