#!/usr/bin/env python3 """ 꼬리잡기 후보(DB SHORT 유니버스) — 진입 후 고점(MFE) vs 래칫 조기청산 검증. 사용: python3 -m kis_trader.backtest.tail_mfe_analysis --start 2026-06-01 --end 2026-06-22 """ from __future__ import annotations import argparse from collections import Counter from datetime import datetime from typing import Any, Dict, List, Optional, Tuple from database import TradeDB from kis_trader.backtest import tail_backtest_common as tbc from kis_trader.engine import tail_engine as te def _t2dt(candle_time: str) -> datetime: from kis_trader.utils.trade_time import parse_trade_datetime return parse_trade_datetime(candle_time) def _session_peak_after_entry( candles: List[Dict], entry_time: str, entry_price: float, ) -> Tuple[float, float, str]: """당일 진입 이후 세션 고점·최대수익%·고점시각.""" day = str(entry_time)[:8] ep = float(entry_price) if ep <= 0: return ep, 0.0, entry_time peak = ep peak_t = entry_time started = False for c in candles: ct = str(c.get("candle_time") or "") if ct[:8] != day: continue if not started: if ct < str(entry_time)[:12]: continue started = True hi = float(c.get("high") or c.get("close") or 0) if hi > peak: peak = hi peak_t = ct mfe_pct = (peak - ep) / ep * 100.0 return peak, mfe_pct, peak_t def _avg_profit_rate(trades: List[Dict]) -> Tuple[float, float]: wins = [t for t in trades if float(t.get("pnl") or 0) > 0] losses = [t for t in trades if float(t.get("pnl") or 0) <= 0] aw = ( sum((float(t["exit"]) - float(t["entry"])) / float(t["entry"]) * 100 for t in wins) / len(wins) if wins else 0.0 ) al = ( sum((float(t["exit"]) - float(t["entry"])) / float(t["entry"]) * 100 for t in losses) / len(losses) if losses else 0.0 ) return aw, al def _run_scenario( label: str, candles_by_code: Dict[str, List[Dict]], universe_by_slot: Optional[Dict[str, List[str]]], base: Dict[str, Any], patch: Dict[str, Any], slot: float, fee_rate: float, sell_tax: float, mxs: int, tb: float, ) -> Dict[str, Any]: params = dict(base) params.update(patch) trades = tbc.run_tail_backtest_web_aligned( candles_by_code, params, universe_by_slot, slot_money=slot, fee_rate=fee_rate, sell_tax=sell_tax, max_stocks=mxs, total_budget_krw=tb, ) stats = tbc.summarize_tail_trades(trades, total_budget_krw=tb) aw, al = _avg_profit_rate(trades) reasons = Counter(str(t.get("reason") or t.get("sell_reason") or "?") for t in trades) return { "label": label, "trades": trades, "stats": stats, "avg_win_pct": aw, "avg_loss_pct": al, "reasons": dict(reasons), } def analyze_mfe_vs_exit( baseline_trades: List[Dict], candles_by_code: Dict[str, List[Dict]], ) -> Dict[str, Any]: """현재(래칫ON) 체결 건마다 — 실제청산% vs 당일잔여고점(MFE)% 비교.""" rows: List[Dict[str, Any]] = [] for t in baseline_trades: code = str(t.get("code") or "") candles = candles_by_code.get(code) or [] if not candles: continue ep = float(t["entry"]) xp = float(t["exit"]) et = str(t["entry_time"]) xt = str(t["exit_time"]) exit_pct = (xp - ep) / ep * 100.0 peak, mfe_pct, peak_t = _session_peak_after_entry(candles, et, ep) left_pct = mfe_pct - exit_pct rows.append({ "code": code, "entry_time": et, "exit_time": xt, "reason": t.get("reason"), "exit_pct": round(exit_pct, 2), "mfe_pct": round(mfe_pct, 2), "left_on_table_pct": round(left_pct, 2), "peak_time": peak_t, "reached_5pct": mfe_pct >= 5.0, "reached_3pct": mfe_pct >= 3.0, }) n = len(rows) if n == 0: return {"count": 0} avg_exit = sum(r["exit_pct"] for r in rows) / n avg_mfe = sum(r["mfe_pct"] for r in rows) / n avg_left = sum(r["left_on_table_pct"] for r in rows) / n cnt_5 = sum(1 for r in rows if r["reached_5pct"]) cnt_3 = sum(1 for r in rows if r["reached_3pct"]) cnt_left_2 = sum(1 for r in rows if r["left_on_table_pct"] >= 2.0) top_left = sorted(rows, key=lambda x: x["left_on_table_pct"], reverse=True)[:10] return { "count": n, "avg_exit_pct": round(avg_exit, 2), "avg_mfe_pct": round(avg_mfe, 2), "avg_left_on_table_pct": round(avg_left, 2), "reached_5pct_count": cnt_5, "reached_5pct_rate": round(cnt_5 / n * 100, 1), "reached_3pct_count": cnt_3, "reached_3pct_rate": round(cnt_3 / n * 100, 1), "left_ge_2pct_count": cnt_left_2, "left_ge_2pct_rate": round(cnt_left_2 / n * 100, 1), "top_left_on_table": top_left, } def main() -> None: parser = argparse.ArgumentParser(description="꼬리 후보 MFE vs 래칫 검증") parser.add_argument("--start", default="2026-06-01") parser.add_argument("--end", default="2026-06-30") parser.add_argument("--tf", type=int, default=3) args = parser.parse_args() db = TradeDB() start_key, end_key, start_ymd, end_ymd = tbc.date_keys(args.start, args.end) candles_by_code, total_candles, _ = tbc.load_tail_candles_by_code( db, start_key, end_key, args.tf, rsi_period=14, ) universe_by_slot, src, slot_cnt, _ = tbc.resolve_tail_universe( start_ymd, end_ymd, use_saved_history=True, strategy_id="SHORT", ) snap = db.get_merged_env_snapshot() fee_rate, sell_tax, slot = tbc.fee_and_slot_from_env_row(snap) base = te.get_tail_defaults_from_db(db) base["portfolio_mode"] = True base["force_eod_exit"] = False mxs = int(base.get("max_stocks") or 3) tb = float(base.get("total_budget_krw") or 0) if tb <= 0: tb = float(mxs * slot) period_days = max( 1, (datetime.strptime(args.end, "%Y-%m-%d") - datetime.strptime(args.start, "%Y-%m-%d")).days + 1, ) print("=" * 72) print(f"꼬리잡기 MFE 검증 {args.start} ~ {args.end} TF={args.tf}m") print(f"유니버스: {src} slots={slot_cnt} 캔들종목={len(candles_by_code)} 봉={total_candles}") print(f"래칫(현재DB): {base.get('ratchet_tiers')}") print(f"손절ATR mult={base.get('stop_atr_mult')} 익절TP={base.get('take_profit_pct')}") print("=" * 72) _no_shoulder = { "shoulder_min_high": 0.99, "shoulder_cut_pct": 0.99, "trail_pct": 0.0, "trail_arm_pct": 0.0, } scenarios = [ ("①현재DB(래칫ON)", {}), ("②래칫OFF(ATR익절만)", {**_no_shoulder, "ratchet_tiers": ""}), ("③래칫1%이후(1:0.5,3:0.8)", { **_no_shoulder, "ratchet_tiers": "1:0.5,3:0.8", }), ("④래칫2%이후(2:0.5,5:0.8)", { **_no_shoulder, "ratchet_tiers": "2:0.5,5:0.8", }), ("⑤래칫3%이후(3:1.0,5:0.8)", { **_no_shoulder, "ratchet_tiers": "3:1.0,5:0.8", }), ("⑥래칫5%이후(5:1.0,8:0.8)", { **_no_shoulder, "ratchet_tiers": "5:1.0,8:0.8", }), ("⑦어깨만(래칫OFF·0.5%/0.2%)", { "ratchet_tiers": "", "shoulder_min_high": 0.005, "shoulder_cut_pct": 0.002, "trail_pct": 0.0, "trail_arm_pct": 0.0, }), ("⑧현재래칫+손절ATR2.0", {"stop_atr_mult": 2.0}), ("⑨현재래칫+손절ATR1.0", {"stop_atr_mult": 1.0}), ] results = [] for label, patch in scenarios: r = _run_scenario( label, candles_by_code, universe_by_slot, base, patch, slot, fee_rate, sell_tax, mxs, tb, ) results.append(r) s = r["stats"] rr = abs(r["avg_win_pct"] / r["avg_loss_pct"]) if r["avg_loss_pct"] else 0 print( f"\n{label}\n" f" 거래 {s['total_trades']:4} | 승률 {s['win_rate']:5.1f}% | " f"PnL {s['total_pnl']:>10,} | PF {s['pf']:.2f} | 보유 {s['avg_hold_min']:.0f}분\n" f" 평균익 {r['avg_win_pct']:+.2f}% | 평균손 {r['avg_loss_pct']:+.2f}% | R:R {rr:.2f}\n" f" 청산: {r['reasons']}" ) baseline = results[0]["trades"] mfe = analyze_mfe_vs_exit(baseline, candles_by_code) print("\n" + "=" * 72) print("【핵심】①현재(래칫ON) 체결 건 — 실제청산 vs 당일 잔여 고점(MFE)") print("=" * 72) if mfe.get("count", 0) == 0: print("체결 0건 — MFE 분석 불가") else: print(f" 분석건수: {mfe['count']}") print(f" 평균 실제청산: {mfe['avg_exit_pct']:+.2f}%") print(f" 평균 당일고점(MFE): {mfe['avg_mfe_pct']:+.2f}%") print(f" 평균 놓친 수익: {mfe['avg_left_on_table_pct']:+.2f}%p") print( f" +3% 이상 갔던 비율: {mfe['reached_3pct_count']}/{mfe['count']} " f"({mfe['reached_3pct_rate']}%)" ) print( f" +5% 이상 갔던 비율: {mfe['reached_5pct_count']}/{mfe['count']} " f"({mfe['reached_5pct_rate']}%)" ) print( f" 2%p 이상 더 갈 수 있었던 건: {mfe['left_ge_2pct_count']}/{mfe['count']} " f"({mfe['left_ge_2pct_rate']}%)" ) print("\n ▶ 놓친 수익 TOP10 (실제청산 vs 당일고점)") for i, row in enumerate(mfe["top_left_on_table"], 1): print( f" {i:2}. {row['code']} {row['entry_time']} " f"청산{row['exit_pct']:+.1f}%({row['reason']}) " f"→ 고점{row['mfe_pct']:+.1f}%(@{row['peak_time']}) " f"놓침{row['left_on_table_pct']:+.1f}%p" ) print("\n" + "=" * 72) print("시나리오 요약 비교 (PnL 내림차순)") print("=" * 72) ranked = sorted(results, key=lambda r: r["stats"]["total_pnl"], reverse=True) print(f"{'순위':<4} {'시나리오':<28} {'거래':>5} {'승률':>6} {'평균익':>7} {'평균손':>7} {'R:R':>5} {'PF':>5} {'PnL':>12}") for i, r in enumerate(ranked, 1): s = r["stats"] rr = abs(r["avg_win_pct"] / r["avg_loss_pct"]) if r["avg_loss_pct"] else 0 mark = " ★현재" if r["label"].startswith("①") else "" print( f"{i:<4} {r['label']:<28} {s['total_trades']:5} {s['win_rate']:5.1f}% " f"{r['avg_win_pct']:+6.2f}% {r['avg_loss_pct']:+6.2f}% {rr:5.2f} " f"{s['pf']:5.2f} {s['total_pnl']:12,}{mark}" ) best = ranked[0] cur = next(r for r in results if r["label"].startswith("①")) cur_rank = next(i for i, r in enumerate(ranked, 1) if r["label"].startswith("①")) print( f"\n▶ 1위: {best['label']} PnL {best['stats']['total_pnl']:,}원 | " f"현재DB 순위: {cur_rank}위 PnL {cur['stats']['total_pnl']:,}원" ) if __name__ == "__main__": main()