From 90d99a166233ceeaa0d4dc096a0f23965a80e67e Mon Sep 17 00:00:00 2001 From: 21in7 Date: Sat, 7 Mar 2026 00:46:03 +0900 Subject: [PATCH] feat(weekly-report): add Discord report formatting and sending Co-Authored-By: Claude Opus 4.6 --- scripts/weekly_report.py | 124 ++++++++++++++++++++++++++++++++++++ tests/test_weekly_report.py | 69 ++++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/scripts/weekly_report.py b/scripts/weekly_report.py index 88bc0bb..f99b4ec 100644 --- a/scripts/weekly_report.py +++ b/scripts/weekly_report.py @@ -12,6 +12,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) import json +import os import re import subprocess from datetime import date, timedelta @@ -19,6 +20,7 @@ from datetime import date, timedelta from loguru import logger from src.backtester import WalkForwardBacktester, WalkForwardConfig +from src.notifier import DiscordNotifier # ── 프로덕션 파라미터 ────────────────────────────────────────────── @@ -213,3 +215,125 @@ def run_degradation_sweep( reverse=True, ) return results[:top_n] + + +# ── Discord 리포트 포맷 & 전송 ───────────────────────────────────── + +_EMOJI_CHART = "\U0001F4CA" +_EMOJI_ALERT = "\U0001F6A8" +_EMOJI_BELL = "\U0001F514" +_CHECK = "\u2705" +_UNCHECK = "\u2610" +_WARN = "\u26A0" +_ARROW = "\u2192" + + +def format_report(data: dict) -> str: + """리포트 데이터를 Discord 메시지 텍스트로 포맷한다.""" + d = data["date"] + bt = data["backtest"]["summary"] + pf = bt["profit_factor"] + pf_str = f"{pf:.2f}" if pf != float("inf") else "INF" + + status = "" + if pf < 1.0: + status = f" {_EMOJI_ALERT} 손실 구간" + + lines = [ + f"{_EMOJI_CHART} 주간 전략 리포트 ({d})", + "", + "[현재 성능 — Walk-Forward 백테스트]", + f" 합산 PF: {pf_str} | 승률: {bt['win_rate']:.0f}% | MDD: {bt['max_drawdown_pct']:.0f}%{status}", + ] + + # 심볼별 성능 + per_sym = data["backtest"].get("per_symbol", {}) + if per_sym: + sym_parts = [] + for sym, s in per_sym.items(): + short = sym.replace("USDT", "") + spf = f"{s['profit_factor']:.2f}" if s["profit_factor"] != float("inf") else "INF" + sym_parts.append(f"{short}: PF {spf} ({s['total_trades']}건)") + lines.append(f" {' | '.join(sym_parts)}") + + # 실전 트레이드 + lt = data["live_trades"] + if lt["count"] > 0: + lines += [ + "", + "[실전 트레이드 (이번 주)]", + f" 거래: {lt['count']}건 | 순수익: {lt['net_pnl']:+.2f} USDT | 승률: {lt['win_rate']:.1f}%", + ] + + # 추이 + trend = data["trend"] + if trend["pf"]: + pf_trend = f" {_ARROW} ".join(f"{v:.2f}" for v in trend["pf"]) + warn = f" {_WARN} 하락 추세" if trend["pf_declining_3w"] else "" + pf_len = len(trend["pf"]) + lines += ["", f"[추이 (최근 {pf_len}주)]", f" PF: {pf_trend}{warn}"] + if trend["win_rate"]: + wr_trend = f" {_ARROW} ".join(f"{v:.0f}%" for v in trend["win_rate"]) + lines.append(f" 승률: {wr_trend}") + if trend["mdd"]: + mdd_trend = f" {_ARROW} ".join(f"{v:.0f}%" for v in trend["mdd"]) + lines.append(f" MDD: {mdd_trend}") + + # ML 재도전 체크리스트 + ml = data["ml_trigger"] + cond = ml["conditions"] + threshold = ml["threshold"] + cum_trades = ml["cumulative_trades"] + + c1 = _CHECK if cond["cumulative_trades_enough"] else _UNCHECK + c2 = _CHECK if cond["pf_below_1"] else _UNCHECK + c3 = _CHECK if cond["pf_declining_3w"] else _UNCHECK + pf_below_label = "예" if cond["pf_below_1"] else "아니오" + pf_dec_label = f"예 {_WARN}" if cond["pf_declining_3w"] else "아니오" + + lines += [ + "", + "[ML 재도전 체크리스트]", + f" {c1} 누적 트레이드 \u2265 {threshold}건: {cum_trades}/{threshold}", + f" {c2} PF < 1.0: {pf_below_label} (현재 {pf_str})", + f" {c3} PF 3주 연속 하락: {pf_dec_label}", + ] + met_count = ml["met_count"] + if ml["recommend"]: + lines.append(f" {_ARROW} {_EMOJI_BELL} ML 재학습 권장! ({met_count}/3 충족)") + else: + lines.append(f" {_ARROW} ML 재도전 시점: 아직 아님 ({met_count}/3 충족)") + + # 파라미터 스윕 + sweep = data.get("sweep") + if sweep: + lines += ["", "[파라미터 스윕 결과]"] + lines.append(f" 현재: {_param_str(PROD_PARAMS)} {_ARROW} PF {pf_str}") + for i, alt in enumerate(sweep): + apf = alt["summary"]["profit_factor"] + apf_str = f"{apf:.2f}" if apf != float("inf") else "INF" + diff = apf - pf + idx = i + 1 + lines.append(f" 대안 {idx}: {_param_str(alt['params'])} {_ARROW} PF {apf_str} ({diff:+.2f})") + lines.append("") + lines.append(f" {_WARN} 자동 적용되지 않음. 검토 후 승인 필요.") + elif pf >= 1.0: + lines += ["", "[파라미터 스윕]", " 현재 파라미터가 최적 — 스윕 불필요"] + + return "\n".join(lines) + + +def _param_str(p: dict) -> str: + return (f"SL={p.get('atr_sl_mult', '?')}, TP={p.get('atr_tp_mult', '?')}, " + f"ADX={p.get('adx_threshold', '?')}, Vol={p.get('volume_multiplier', '?')}") + + +def send_report(content: str, webhook_url: str | None = None) -> None: + """Discord 웹훅으로 리포트를 전송한다.""" + url = webhook_url or os.getenv("DISCORD_WEBHOOK_URL", "") + if not url: + logger.warning("DISCORD_WEBHOOK_URL이 설정되지 않아 전송 스킵") + return + notifier = DiscordNotifier(url) + notifier._send(content) + logger.info("Discord 리포트 전송 완료") diff --git a/tests/test_weekly_report.py b/tests/test_weekly_report.py index 8b1639d..5deb1f8 100644 --- a/tests/test_weekly_report.py +++ b/tests/test_weekly_report.py @@ -163,3 +163,72 @@ def test_run_degradation_sweep_returns_top_n(): assert len(alternatives) <= 3 assert alternatives[0]["summary"]["profit_factor"] >= alternatives[1]["summary"]["profit_factor"] + + +def test_format_report_normal(): + """정상 상태(PF >= 1.0) 리포트 포맷.""" + from scripts.weekly_report import format_report + + report_data = { + "date": "2026-03-07", + "backtest": { + "summary": { + "profit_factor": 1.24, "win_rate": 45.0, + "max_drawdown_pct": 12.0, "total_trades": 88, + }, + "per_symbol": { + "XRPUSDT": {"profit_factor": 1.57, "total_trades": 27, "win_rate": 66.7}, + "TRXUSDT": {"profit_factor": 1.29, "total_trades": 25, "win_rate": 52.0}, + "DOGEUSDT": {"profit_factor": 1.09, "total_trades": 36, "win_rate": 44.4}, + }, + }, + "live_trades": {"count": 8, "net_pnl": 12.34, "win_rate": 62.5}, + "trend": {"pf": [1.31, 1.24], "win_rate": [48.0, 45.0], "mdd": [9.0, 12.0], "pf_declining_3w": False}, + "ml_trigger": {"recommend": False, "met_count": 0, "conditions": { + "cumulative_trades_enough": False, "pf_below_1": False, "pf_declining_3w": False, + }, "cumulative_trades": 47, "threshold": 150}, + "sweep": None, + } + + text = format_report(report_data) + assert "\uc8fc\uac04 \uc804\ub7b5 \ub9ac\ud3ec\ud2b8" in text + assert "1.24" in text + assert "XRPUSDT" in text or "XRP" in text + + +def test_format_report_degraded(): + """PF < 1.0일 때 스윕 결과 + ML 권장이 포함되는지 확인.""" + from scripts.weekly_report import format_report + + report_data = { + "date": "2026-06-07", + "backtest": { + "summary": {"profit_factor": 0.87, "win_rate": 38.0, "max_drawdown_pct": 22.0, "total_trades": 90}, + "per_symbol": {}, + }, + "live_trades": {"count": 0, "net_pnl": 0, "win_rate": 0}, + "trend": {"pf": [1.1, 1.0, 0.87], "win_rate": [], "mdd": [], "pf_declining_3w": True}, + "ml_trigger": {"recommend": True, "met_count": 3, "conditions": { + "cumulative_trades_enough": True, "pf_below_1": True, "pf_declining_3w": True, + }, "cumulative_trades": 182, "threshold": 150}, + "sweep": [ + {"params": {"atr_sl_mult": 2.0, "atr_tp_mult": 2.5, "adx_threshold": 30, "volume_multiplier": 2.5, "signal_threshold": 3}, + "summary": {"profit_factor": 1.15, "total_trades": 30}}, + ], + } + + text = format_report(report_data) + assert "0.87" in text + assert "ML" in text + assert "1.15" in text + + +def test_send_report_uses_notifier(): + """Discord 웹훅으로 리포트를 전송.""" + from scripts.weekly_report import send_report + from unittest.mock import patch + + with patch("scripts.weekly_report.DiscordNotifier") as MockNotifier: + instance = MockNotifier.return_value + send_report("test report content", webhook_url="https://example.com/webhook") + instance._send.assert_called_once_with("test report content")