변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
306 lines
11 KiB
Python
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()
|