feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
304
kis_trader/backtest/tail_mfe_analysis.py
Normal file
304
kis_trader/backtest/tail_mfe_analysis.py
Normal file
@@ -0,0 +1,304 @@
|
||||
#!/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:
|
||||
return datetime.strptime(str(candle_time)[:12], "%Y%m%d%H%M")
|
||||
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user