From b86aa8b07228543814e62c016f49cacee2520aaa Mon Sep 17 00:00:00 2001 From: 21in7 Date: Wed, 18 Mar 2026 23:58:22 +0900 Subject: [PATCH] feat(weekly-report): add kill switch monitoring section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Load trade history from data/trade_history/{symbol}.jsonl - Show per-symbol: consecutive loss streak vs threshold, recent 15-trade PF - 2-tier alert: clean numbers for normal, ⚠/πŸ”΄ KILLED for danger zone - Inserted before ML retraining checklist in Discord report Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/weekly_report.py | 81 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/scripts/weekly_report.py b/scripts/weekly_report.py index b6b1557..ecd9576 100644 --- a/scripts/weekly_report.py +++ b/scripts/weekly_report.py @@ -198,6 +198,60 @@ def run_degradation_sweep( return results[:top_n] +# ── ν‚¬μŠ€μœ„μΉ˜ λͺ¨λ‹ˆν„°λ§ ────────────────────────────────────────────── +_KILL_HISTORY_DIR = Path("data/trade_history") +_FAST_KILL_STREAK = 8 +_SLOW_KILL_WINDOW = 15 +_SLOW_KILL_PF_THRESHOLD = 0.75 + + +def load_kill_switch_status(symbols: list[str]) -> dict[str, dict]: + """심볼별 ν‚¬μŠ€μœ„μΉ˜ μ§€ν‘œλ₯Ό 거래 이λ ₯ νŒŒμΌμ—μ„œ μ‚°μΆœν•œλ‹€.""" + result = {} + for sym in symbols: + path = _KILL_HISTORY_DIR / f"{sym.lower()}.jsonl" + trades: list[dict] = [] + if path.exists(): + try: + with open(path) as f: + for line in f: + line = line.strip() + if line: + trades.append(json.loads(line)) + except Exception: + pass + + # ν˜„μž¬ 연속 손싀 카운트 (λ’€μ—μ„œλΆ€ν„°) + consec_loss = 0 + for t in reversed(trades): + if t.get("net_pnl", 0) < 0: + consec_loss += 1 + else: + break + + # 졜근 15거래 PF + recent_pf = None + if len(trades) >= _SLOW_KILL_WINDOW: + recent = trades[-_SLOW_KILL_WINDOW:] + gp = sum(t["net_pnl"] for t in recent if t["net_pnl"] > 0) + gl = abs(sum(t["net_pnl"] for t in recent if t["net_pnl"] < 0)) + recent_pf = round(gp / gl, 2) if gl > 0 else float("inf") + + # 킬 μƒνƒœ νŒμ • + killed = ( + consec_loss >= _FAST_KILL_STREAK + or (recent_pf is not None and recent_pf < _SLOW_KILL_PF_THRESHOLD) + ) + + result[sym] = { + "total_trades": len(trades), + "consec_loss": consec_loss, + "recent_pf": recent_pf, + "killed": killed, + } + return result + + # ── Discord 리포트 포맷 & 전솑 ───────────────────────────────────── _EMOJI_CHART = "\U0001F4CA" @@ -260,6 +314,27 @@ def format_report(data: dict) -> str: mdd_trend = f" {_ARROW} ".join(f"{v:.0f}%" for v in trend["mdd"]) lines.append(f" MDD: {mdd_trend}") + # ν‚¬μŠ€μœ„μΉ˜ λͺ¨λ‹ˆν„°λ§ + ks = data.get("kill_switch", {}) + if ks: + lines += ["", "[ν‚¬μŠ€μœ„μΉ˜ λͺ¨λ‹ˆν„°λ§]"] + for sym, status in ks.items(): + short = sym.replace("USDT", "") + cl = status["consec_loss"] + # 연속 손싀 κ²½κ³ : 6회 이상이면 ⚠ + cl_warn = f" {_WARN}" if cl >= 6 else "" + cl_str = f"연속손싀 {cl}/{_FAST_KILL_STREAK}{cl_warn}" + # PF ν‘œμ‹œ + rpf = status["recent_pf"] + if rpf is not None: + pf_str = f"{_SLOW_KILL_WINDOW}거래PF {rpf:.2f}" + else: + n = status["total_trades"] + pf_str = f"{_SLOW_KILL_WINDOW}거래PF -.-- ({n}건)" + # KILLED ν‘œμ‹œ + kill_tag = " \U0001F534 KILLED" if status["killed"] else "" + lines.append(f" {short}: {cl_str} | {pf_str}{kill_tag}") + # ML μž¬λ„μ „ 체크리슀트 ml = data["ml_trigger"] cond = ml["conditions"] @@ -478,7 +553,10 @@ def generate_report( pf_declining_3w=trend["pf_declining_3w"], ) - # 6) PF < 1.0이면 μŠ€μœ• μ‹€ν–‰ + # 6) ν‚¬μŠ€μœ„μΉ˜ λͺ¨λ‹ˆν„°λ§ + kill_switch = load_kill_switch_status(symbols) + + # 7) PF < 1.0이면 μŠ€μœ• μ‹€ν–‰ sweep = None if current_pf < 1.0: logger.info("PF < 1.0 β€” νŒŒλΌλ―Έν„° μŠ€μœ• μ‹€ν–‰ 쀑...") @@ -489,6 +567,7 @@ def generate_report( "backtest": {"summary": backtest_summary, "per_symbol": bt_results, "trades": all_bt_trades}, "live_trades": live_summary, "trend": trend, + "kill_switch": kill_switch, "ml_trigger": ml_trigger, "sweep": sweep, }