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 <noreply@anthropic.com>
This commit is contained in:
@@ -14,3 +14,4 @@ joblib>=1.3.0
|
|||||||
pyarrow>=15.0.0
|
pyarrow>=15.0.0
|
||||||
onnxruntime>=1.18.0
|
onnxruntime>=1.18.0
|
||||||
optuna>=3.6.0
|
optuna>=3.6.0
|
||||||
|
quantstats>=0.0.81
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from datetime import date, timedelta
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -336,6 +337,44 @@ def _sanitize(obj):
|
|||||||
return 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:
|
def save_report(report: dict, report_dir: str) -> Path:
|
||||||
"""리포트를 JSON으로 저장하고 경로를 반환한다."""
|
"""리포트를 JSON으로 저장하고 경로를 반환한다."""
|
||||||
rdir = Path(report_dir)
|
rdir = Path(report_dir)
|
||||||
@@ -360,6 +399,7 @@ def generate_report(
|
|||||||
# 1) Walk-Forward 백테스트 (심볼별)
|
# 1) Walk-Forward 백테스트 (심볼별)
|
||||||
logger.info("백테스트 실행 중...")
|
logger.info("백테스트 실행 중...")
|
||||||
bt_results = {}
|
bt_results = {}
|
||||||
|
all_bt_trades = []
|
||||||
combined_trades = 0
|
combined_trades = 0
|
||||||
combined_pnl = 0.0
|
combined_pnl = 0.0
|
||||||
combined_gp = 0.0
|
combined_gp = 0.0
|
||||||
@@ -368,6 +408,7 @@ def generate_report(
|
|||||||
for sym in symbols:
|
for sym in symbols:
|
||||||
result = run_backtest([sym], TRAIN_MONTHS, TEST_MONTHS, PROD_PARAMS)
|
result = run_backtest([sym], TRAIN_MONTHS, TEST_MONTHS, PROD_PARAMS)
|
||||||
bt_results[sym] = result["summary"]
|
bt_results[sym] = result["summary"]
|
||||||
|
all_bt_trades.extend(result.get("trades", []))
|
||||||
s = result["summary"]
|
s = result["summary"]
|
||||||
n = s["total_trades"]
|
n = s["total_trades"]
|
||||||
combined_trades += n
|
combined_trades += n
|
||||||
@@ -437,7 +478,7 @@ def generate_report(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"date": today.isoformat(),
|
"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,
|
"live_trades": live_summary,
|
||||||
"trend": trend,
|
"trend": trend,
|
||||||
"ml_trigger": ml_trigger,
|
"ml_trigger": ml_trigger,
|
||||||
@@ -463,7 +504,13 @@ def main():
|
|||||||
# 3) 저장
|
# 3) 저장
|
||||||
save_report(report, str(WEEKLY_DIR))
|
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)
|
text = format_report(report)
|
||||||
print(text)
|
print(text)
|
||||||
send_report(text)
|
send_report(text)
|
||||||
|
|||||||
@@ -303,3 +303,32 @@ def test_save_report_creates_json(tmp_path):
|
|||||||
assert len(saved) == 1
|
assert len(saved) == 1
|
||||||
loaded = json.loads(saved[0].read_text())
|
loaded = json.loads(saved[0].read_text())
|
||||||
assert loaded["date"] == "2026-03-07"
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user