From 58596785aa1d3403c83b5cb942d66efcd032748a Mon Sep 17 00:00:00 2001 From: 21in7 Date: Sat, 7 Mar 2026 00:42:53 +0900 Subject: [PATCH] feat(weekly-report): add ML trigger check and degradation sweep Co-Authored-By: Claude Opus 4.6 --- scripts/weekly_report.py | 56 +++++++++++++++++++++++++++++++++++++ tests/test_weekly_report.py | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/scripts/weekly_report.py b/scripts/weekly_report.py index 8edddba..88bc0bb 100644 --- a/scripts/weekly_report.py +++ b/scripts/weekly_report.py @@ -157,3 +157,59 @@ def load_trend(report_dir: str, weeks: int = 4) -> dict: "mdd": mdd_list, "pf_declining_3w": declining, } + + +# ── ML 재학습 트리거 & 성능 저하 스윕 ───────────────────────────── +from scripts.strategy_sweep import ( + run_single_backtest, + generate_combinations, + PARAM_GRID, +) + +ML_TRADE_THRESHOLD = 150 + + +def check_ml_trigger( + cumulative_trades: int, + current_pf: float, + pf_declining_3w: bool, +) -> dict: + """ML 재학습 조건 체크. 3개 중 2개 이상 충족 시 권장.""" + conditions = { + "cumulative_trades_enough": cumulative_trades >= ML_TRADE_THRESHOLD, + "pf_below_1": current_pf < 1.0, + "pf_declining_3w": pf_declining_3w, + } + met = sum(conditions.values()) + return { + "conditions": conditions, + "met_count": met, + "recommend": met >= 2, + "cumulative_trades": cumulative_trades, + "threshold": ML_TRADE_THRESHOLD, + } + + +def run_degradation_sweep( + symbols: list[str], + train_months: int, + test_months: int, + top_n: int = 3, +) -> list[dict]: + """전체 파라미터 스윕을 실행하고 PF 상위 N개 대안을 반환한다.""" + combos = generate_combinations(PARAM_GRID) + results = [] + + for params in combos: + try: + summary = run_single_backtest(symbols, params, train_months, test_months) + results.append({"params": params, "summary": summary}) + except Exception as e: + logger.warning(f"스윕 실패: {e}") + + results.sort( + key=lambda r: r["summary"]["profit_factor"] + if r["summary"]["profit_factor"] != float("inf") else 999, + reverse=True, + ) + return results[:top_n] diff --git a/tests/test_weekly_report.py b/tests/test_weekly_report.py index 598e5fa..8b1639d 100644 --- a/tests/test_weekly_report.py +++ b/tests/test_weekly_report.py @@ -110,3 +110,56 @@ def test_load_trend_empty_dir(tmp_path): trend = load_trend(str(tmp_path), weeks=4) assert trend["pf"] == [] assert trend["pf_declining_3w"] is False + + +def test_check_ml_trigger_all_met(): + """3개 조건 모두 충족 시 recommend=True.""" + from scripts.weekly_report import check_ml_trigger + + result = check_ml_trigger( + cumulative_trades=200, current_pf=0.85, pf_declining_3w=True, + ) + assert result["recommend"] is True + assert result["met_count"] == 3 + + +def test_check_ml_trigger_none_met(): + """조건 미충족 시 recommend=False.""" + from scripts.weekly_report import check_ml_trigger + + result = check_ml_trigger( + cumulative_trades=50, current_pf=1.5, pf_declining_3w=False, + ) + assert result["recommend"] is False + assert result["met_count"] == 0 + + +def test_run_degradation_sweep_returns_top_n(): + """스윕을 실행하고 PF 상위 N개 대안을 반환.""" + from scripts.weekly_report import run_degradation_sweep + from unittest.mock import patch + + fake_summaries = [ + {"profit_factor": 1.15, "total_trades": 30, "total_pnl": 50, "return_pct": 5, + "win_rate": 55, "avg_win": 10, "avg_loss": -8, "max_drawdown_pct": 10, + "sharpe_ratio": 2.0, "total_fees": 1, "close_reasons": {}}, + {"profit_factor": 1.08, "total_trades": 25, "total_pnl": 30, "return_pct": 3, + "win_rate": 50, "avg_win": 8, "avg_loss": -7, "max_drawdown_pct": 12, + "sharpe_ratio": 1.5, "total_fees": 1, "close_reasons": {}}, + {"profit_factor": 0.95, "total_trades": 20, "total_pnl": -10, "return_pct": -1, + "win_rate": 40, "avg_win": 6, "avg_loss": -9, "max_drawdown_pct": 15, + "sharpe_ratio": 0.5, "total_fees": 1, "close_reasons": {}}, + ] + fake_combos = [ + {"atr_sl_mult": 1.5}, {"atr_sl_mult": 1.0}, {"atr_sl_mult": 2.0}, + ] + + with patch("scripts.weekly_report.run_single_backtest") as mock_bt: + mock_bt.side_effect = fake_summaries + with patch("scripts.weekly_report.generate_combinations", return_value=fake_combos): + alternatives = run_degradation_sweep( + symbols=["XRPUSDT"], train_months=3, test_months=1, top_n=3, + ) + + assert len(alternatives) <= 3 + assert alternatives[0]["summary"]["profit_factor"] >= alternatives[1]["summary"]["profit_factor"]