Files
kis_bot/kis_trader/backtest/tail_mfe_analysis.py
Your Name fc27e726f9 feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선
변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
2026-07-17 01:09:09 +09:00

306 lines
11 KiB
Python

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