diff --git a/scripts/weekly_report.py b/scripts/weekly_report.py index 7ec3bb1..b6b1557 100644 --- a/scripts/weekly_report.py +++ b/scripts/weekly_report.py @@ -386,6 +386,39 @@ def save_report(report: dict, report_dir: str) -> Path: return path +def _calc_combined_summary(trades: list[dict], initial_balance: float = 1000.0) -> dict: + """개별 트레이드 리스트에서 합산 지표를 직접 계산한다.""" + if not trades: + return { + "profit_factor": 0.0, "win_rate": 0.0, "max_drawdown_pct": 0.0, + "total_trades": 0, "total_pnl": 0.0, + } + + pnls = [t["net_pnl"] for t in trades] + wins = [p for p in pnls if p > 0] + losses = [p for p in pnls if p <= 0] + + gross_profit = sum(wins) if wins else 0.0 + gross_loss = abs(sum(losses)) if losses else 0.0 + + # 시간순 정렬 후 포트폴리오 equity curve 기반 MDD + sorted_trades = sorted(trades, key=lambda t: t["exit_time"]) + sorted_pnls = [t["net_pnl"] for t in sorted_trades] + cumulative = np.cumsum(sorted_pnls) + equity = initial_balance + cumulative + peak = np.maximum.accumulate(equity) + drawdown = (peak - equity) / peak + mdd = float(np.max(drawdown)) * 100 if len(drawdown) > 0 else 0.0 + + return { + "profit_factor": round(gross_profit / gross_loss, 2) if gross_loss > 0 else float("inf"), + "win_rate": round(len(wins) / len(trades) * 100, 1), + "max_drawdown_pct": round(mdd, 1), + "total_trades": len(trades), + "total_pnl": round(sum(pnls), 2), + } + + def generate_report( symbols: list[str], report_dir: str = str(WEEKLY_DIR), @@ -400,40 +433,14 @@ def generate_report( logger.info("백테스트 실행 중...") bt_results = {} all_bt_trades = [] - combined_trades = 0 - combined_pnl = 0.0 - combined_gp = 0.0 - combined_gl = 0.0 for sym in symbols: result = run_backtest([sym], TRAIN_MONTHS, TEST_MONTHS, PROD_PARAMS) bt_results[sym] = result["summary"] all_bt_trades.extend(result.get("trades", [])) - s = result["summary"] - n = s["total_trades"] - combined_trades += n - combined_pnl += s["total_pnl"] - if n > 0: - wr = s["win_rate"] / 100.0 - n_wins = round(wr * n) - n_losses = n - n_wins - combined_gp += s["avg_win"] * n_wins if n_wins > 0 else 0 - combined_gl += abs(s["avg_loss"]) * n_losses if n_losses > 0 else 0 - combined_pf = combined_gp / combined_gl if combined_gl > 0 else float("inf") - combined_wr = ( - sum(s["win_rate"] * s["total_trades"] for s in bt_results.values()) - / combined_trades if combined_trades > 0 else 0 - ) - combined_mdd = max((s["max_drawdown_pct"] for s in bt_results.values()), default=0) - - backtest_summary = { - "profit_factor": round(combined_pf, 2), - "win_rate": round(combined_wr, 1), - "max_drawdown_pct": round(combined_mdd, 1), - "total_trades": combined_trades, - "total_pnl": round(combined_pnl, 2), - } + # 합산 지표를 개별 트레이드에서 직접 계산 (간접 역산 제거) + backtest_summary = _calc_combined_summary(all_bt_trades) # 2) 운영 대시보드 API에서 실전 트레이드 조회 logger.info(f"대시보드 API에서 실전 트레이드 조회 중... ({dashboard_url})") @@ -464,15 +471,16 @@ def generate_report( pass # 5) ML 트리거 체크 + current_pf = backtest_summary["profit_factor"] ml_trigger = check_ml_trigger( cumulative_trades=cumulative, - current_pf=combined_pf, + current_pf=current_pf, pf_declining_3w=trend["pf_declining_3w"], ) # 6) PF < 1.0이면 스윕 실행 sweep = None - if combined_pf < 1.0: + if current_pf < 1.0: logger.info("PF < 1.0 — 파라미터 스윕 실행 중...") sweep = run_degradation_sweep(symbols, TRAIN_MONTHS, TEST_MONTHS) diff --git a/tests/test_weekly_report.py b/tests/test_weekly_report.py index 61069b6..8c105af 100644 --- a/tests/test_weekly_report.py +++ b/tests/test_weekly_report.py @@ -262,16 +262,22 @@ def test_generate_report_orchestration(tmp_path): from scripts.weekly_report import generate_report from unittest.mock import patch + # 합산 지표는 개별 트레이드에서 직접 계산되므로 mock에 트레이드 포함 + mock_trades = [ + {"net_pnl": 20.0, "entry_fee": 1.0, "exit_fee": 1.0, "exit_time": "2025-06-10 12:00:00"}, + {"net_pnl": 15.0, "entry_fee": 1.0, "exit_fee": 1.0, "exit_time": "2025-06-11 12:00:00"}, + {"net_pnl": -10.0, "entry_fee": 1.0, "exit_fee": 1.0, "exit_time": "2025-06-12 12:00:00"}, + ] mock_bt_result = { "summary": { "profit_factor": 1.24, "win_rate": 45.0, - "max_drawdown_pct": 12.0, "total_trades": 88, - "total_pnl": 379.0, "return_pct": 37.9, - "avg_win": 20.0, "avg_loss": -10.0, - "sharpe_ratio": 33.0, "total_fees": 5.0, + "max_drawdown_pct": 12.0, "total_trades": 3, + "total_pnl": 25.0, "return_pct": 2.5, + "avg_win": 17.5, "avg_loss": -10.0, + "sharpe_ratio": 33.0, "total_fees": 6.0, "close_reasons": {}, }, - "folds": [], "trades": [], + "folds": [], "trades": mock_trades, } with patch("scripts.weekly_report.run_backtest", return_value=mock_bt_result): @@ -287,8 +293,9 @@ def test_generate_report_orchestration(tmp_path): ) assert report["date"] == "2026-03-07" - # PF는 avg_win/avg_loss에서 재계산됨 (GP=40*20=800, GL=48*10=480 → 1.67) - assert report["backtest"]["summary"]["profit_factor"] == 1.67 + # PF는 개별 트레이드에서 직접 계산: GP=35, GL=10 → 3.5 + assert report["backtest"]["summary"]["profit_factor"] == 3.5 + assert report["backtest"]["summary"]["total_trades"] == 3 assert report["sweep"] is None # PF >= 1.0이면 스윕 안 함