feat(weekly-report): add main orchestration, CLI, JSON save

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
21in7
2026-03-07 00:49:16 +09:00
parent 90d99a1662
commit 1b1542d51f
2 changed files with 201 additions and 0 deletions

View File

@@ -11,12 +11,15 @@ import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
import argparse
import json import json
import os import os
import re import re
import subprocess import subprocess
from datetime import date, timedelta from datetime import date, timedelta
import numpy as np
from loguru import logger from loguru import logger
from src.backtester import WalkForwardBacktester, WalkForwardConfig from src.backtester import WalkForwardBacktester, WalkForwardConfig
@@ -337,3 +340,153 @@ def send_report(content: str, webhook_url: str | None = None) -> None:
notifier = DiscordNotifier(url) notifier = DiscordNotifier(url)
notifier._send(content) notifier._send(content)
logger.info("Discord 리포트 전송 완료") logger.info("Discord 리포트 전송 완료")
def _sanitize(obj):
"""JSON 직렬화를 위해 numpy/inf 값을 변환."""
if isinstance(obj, bool):
return obj
if isinstance(obj, (np.integer,)):
return int(obj)
if isinstance(obj, (np.floating,)):
return float(obj)
if isinstance(obj, float) and (obj == float("inf") or obj == float("-inf")):
return str(obj)
if isinstance(obj, dict):
return {k: _sanitize(v) for k, v in obj.items()}
if isinstance(obj, list):
return [_sanitize(v) for v in obj]
return obj
def save_report(report: dict, report_dir: str) -> Path:
"""리포트를 JSON으로 저장하고 경로를 반환한다."""
rdir = Path(report_dir)
rdir.mkdir(parents=True, exist_ok=True)
path = rdir / f"report_{report['date']}.json"
with open(path, "w") as f:
json.dump(_sanitize(report), f, indent=2, ensure_ascii=False)
logger.info(f"리포트 저장: {path}")
return path
def generate_report(
symbols: list[str],
report_dir: str = str(WEEKLY_DIR),
log_path: str = "logs/bot.log",
report_date: date | None = None,
) -> dict:
"""전체 주간 리포트를 생성한다."""
today = report_date or date.today()
# 1) Walk-Forward 백테스트 (심볼별)
logger.info("백테스트 실행 중...")
bt_results = {}
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"]
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),
}
# 2) 실전 트레이드 파싱
logger.info("실전 로그 파싱 중...")
live_trades_list = parse_live_trades(log_path, days=7)
live_wins = sum(1 for t in live_trades_list if t.get("net_pnl", 0) > 0)
live_pnl = sum(t.get("net_pnl", 0) for t in live_trades_list)
live_summary = {
"count": len(live_trades_list),
"net_pnl": round(live_pnl, 2),
"win_rate": round(live_wins / len(live_trades_list) * 100, 1) if live_trades_list else 0,
}
# 3) 추이 로드
trend = load_trend(report_dir)
# 4) 누적 트레이드 수
cumulative = combined_trades + len(live_trades_list)
rdir = Path(report_dir)
if rdir.exists():
for rpath in sorted(rdir.glob("report_*.json")):
try:
prev = json.loads(rpath.read_text())
cumulative += prev.get("live_trades", {}).get("count", 0)
except (json.JSONDecodeError, KeyError):
pass
# 5) ML 트리거 체크
ml_trigger = check_ml_trigger(
cumulative_trades=cumulative,
current_pf=combined_pf,
pf_declining_3w=trend["pf_declining_3w"],
)
# 6) PF < 1.0이면 스윕 실행
sweep = None
if combined_pf < 1.0:
logger.info("PF < 1.0 — 파라미터 스윕 실행 중...")
sweep = run_degradation_sweep(symbols, TRAIN_MONTHS, TEST_MONTHS)
return {
"date": today.isoformat(),
"backtest": {"summary": backtest_summary, "per_symbol": bt_results},
"live_trades": live_summary,
"trend": trend,
"ml_trigger": ml_trigger,
"sweep": sweep,
}
def main():
parser = argparse.ArgumentParser(description="주간 전략 리포트")
parser.add_argument("--skip-fetch", action="store_true", help="데이터 수집 스킵")
parser.add_argument("--date", type=str, help="리포트 날짜 (YYYY-MM-DD)")
args = parser.parse_args()
report_date = date.fromisoformat(args.date) if args.date else date.today()
# 1) 데이터 수집
if not args.skip_fetch:
fetch_latest_data(SYMBOLS)
# 2) 리포트 생성
report = generate_report(symbols=SYMBOLS, report_date=report_date)
# 3) 저장
save_report(report, str(WEEKLY_DIR))
# 4) Discord 전송
text = format_report(report)
print(text)
send_report(text)
if __name__ == "__main__":
main()

View File

@@ -232,3 +232,51 @@ def test_send_report_uses_notifier():
instance = MockNotifier.return_value instance = MockNotifier.return_value
send_report("test report content", webhook_url="https://example.com/webhook") send_report("test report content", webhook_url="https://example.com/webhook")
instance._send.assert_called_once_with("test report content") instance._send.assert_called_once_with("test report content")
def test_generate_report_orchestration(tmp_path):
"""generate_report가 모든 단계를 조합하여 리포트 dict를 반환."""
from scripts.weekly_report import generate_report
from unittest.mock import patch
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,
"close_reasons": {},
},
"folds": [], "trades": [],
}
with patch("scripts.weekly_report.run_backtest", return_value=mock_bt_result):
with patch("scripts.weekly_report.parse_live_trades", return_value=[]):
with patch("scripts.weekly_report.load_trend", return_value={
"pf": [1.31], "win_rate": [48.0], "mdd": [9.0], "pf_declining_3w": False,
}):
report = generate_report(
symbols=["XRPUSDT"],
report_dir=str(tmp_path),
log_path=str(tmp_path / "bot.log"),
report_date=date(2026, 3, 7),
)
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
assert report["sweep"] is None # PF >= 1.0이면 스윕 안 함
def test_save_report_creates_json(tmp_path):
"""리포트를 JSON으로 저장."""
from scripts.weekly_report import save_report
report = {"date": "2026-03-07", "test": True}
save_report(report, str(tmp_path))
saved = list(tmp_path.glob("report_*.json"))
assert len(saved) == 1
loaded = json.loads(saved[0].read_text())
assert loaded["date"] == "2026-03-07"