From 9b7631350071f803328ba5812e49807f05fc5d44 Mon Sep 17 00:00:00 2001 From: 21in7 Date: Sat, 7 Mar 2026 03:14:30 +0900 Subject: [PATCH] feat: add quantstats HTML report to weekly strategy report - generate_quantstats_report() converts backtest trades to daily returns and generates full HTML report (Sharpe, Sortino, drawdown chart, etc.) - Weekly report now saves report_YYYY-MM-DD.html alongside JSON - Added quantstats to requirements.txt - 2 new tests (HTML generation + empty trades handling) Co-Authored-By: Claude Opus 4.6 --- requirements.txt | 1 + scripts/weekly_report.py | 51 +++++++++++++++++++++++++++++++++++-- tests/test_weekly_report.py | 29 +++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index e287ee6..0f76eed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ joblib>=1.3.0 pyarrow>=15.0.0 onnxruntime>=1.18.0 optuna>=3.6.0 +quantstats>=0.0.81 diff --git a/scripts/weekly_report.py b/scripts/weekly_report.py index aacc1c3..7ec3bb1 100644 --- a/scripts/weekly_report.py +++ b/scripts/weekly_report.py @@ -19,6 +19,7 @@ from datetime import date, timedelta import httpx import numpy as np +import pandas as pd from dotenv import load_dotenv from loguru import logger @@ -336,6 +337,44 @@ def _sanitize(obj): return obj +def generate_quantstats_report( + trades: list[dict], + output_path: str, + title: str = "CoinTrader 주간 전략 리포트", + initial_balance: float = 1000.0, +) -> str | None: + """백테스트 트레이드 결과로 quantstats HTML 리포트를 생성한다.""" + if not trades: + logger.warning("트레이드가 없어 quantstats 리포트를 생성할 수 없습니다.") + return None + + try: + import quantstats as qs + + # 트레이드 PnL을 일별 수익률 시계열로 변환 + records = [] + for t in trades: + exit_time = pd.Timestamp(t["exit_time"]) + records.append({"date": exit_time.date(), "pnl": t["net_pnl"]}) + + df = pd.DataFrame(records) + daily_pnl = df.groupby("date")["pnl"].sum() + daily_pnl.index = pd.to_datetime(daily_pnl.index) + daily_pnl = daily_pnl.sort_index() + + # PnL → 수익률로 변환 (equity 기반) + equity = initial_balance + daily_pnl.cumsum() + returns = equity.pct_change().fillna(daily_pnl.iloc[0] / initial_balance) + + qs.reports.html(returns, output=output_path, title=title, download_filename=output_path) + logger.info(f"quantstats HTML 리포트 저장: {output_path}") + return output_path + + except Exception as e: + logger.warning(f"quantstats 리포트 생성 실패: {e}") + return None + + def save_report(report: dict, report_dir: str) -> Path: """리포트를 JSON으로 저장하고 경로를 반환한다.""" rdir = Path(report_dir) @@ -360,6 +399,7 @@ def generate_report( # 1) Walk-Forward 백테스트 (심볼별) logger.info("백테스트 실행 중...") bt_results = {} + all_bt_trades = [] combined_trades = 0 combined_pnl = 0.0 combined_gp = 0.0 @@ -368,6 +408,7 @@ def generate_report( 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 @@ -437,7 +478,7 @@ def generate_report( return { "date": today.isoformat(), - "backtest": {"summary": backtest_summary, "per_symbol": bt_results}, + "backtest": {"summary": backtest_summary, "per_symbol": bt_results, "trades": all_bt_trades}, "live_trades": live_summary, "trend": trend, "ml_trigger": ml_trigger, @@ -463,7 +504,13 @@ def main(): # 3) 저장 save_report(report, str(WEEKLY_DIR)) - # 4) Discord 전송 + # 4) quantstats HTML 리포트 + bt_trades = report["backtest"].get("trades", []) + if bt_trades: + html_path = str(WEEKLY_DIR / f"report_{report['date']}.html") + generate_quantstats_report(bt_trades, html_path, title=f"CoinTrader 주간 리포트 ({report['date']})") + + # 5) Discord 전송 text = format_report(report) print(text) send_report(text) diff --git a/tests/test_weekly_report.py b/tests/test_weekly_report.py index 2a89ffa..61069b6 100644 --- a/tests/test_weekly_report.py +++ b/tests/test_weekly_report.py @@ -303,3 +303,32 @@ def test_save_report_creates_json(tmp_path): assert len(saved) == 1 loaded = json.loads(saved[0].read_text()) assert loaded["date"] == "2026-03-07" + + +def test_generate_quantstats_report_creates_html(tmp_path): + """트레이드 데이터로 quantstats HTML 리포트를 생성.""" + from scripts.weekly_report import generate_quantstats_report + + trades = [ + {"exit_time": "2026-03-01 12:00:00", "net_pnl": 5.0}, + {"exit_time": "2026-03-02 15:00:00", "net_pnl": -2.0}, + {"exit_time": "2026-03-03 09:00:00", "net_pnl": 8.0}, + {"exit_time": "2026-03-04 18:00:00", "net_pnl": -1.5}, + {"exit_time": "2026-03-05 10:00:00", "net_pnl": 3.0}, + ] + output = str(tmp_path / "test_report.html") + result = generate_quantstats_report(trades, output) + + assert result is not None + assert Path(result).exists() + content = Path(result).read_text() + assert "CoinTrader" in content + + +def test_generate_quantstats_report_empty_trades(tmp_path): + """트레이드가 없으면 None 반환.""" + from scripts.weekly_report import generate_quantstats_report + + output = str(tmp_path / "empty.html") + result = generate_quantstats_report([], output) + assert result is None