Files
cointrader/tests/test_weekly_report.py
21in7 652990082d fix(weekly-report): calculate combined metrics directly from trades
The combined summary (PF, MDD, win_rate) was indirectly reconstructed
from per-symbol averages using round(win_rate * n), which introduced
rounding errors. MDD was max() of individual symbol MDDs, ignoring
simultaneous drawdowns across the correlated crypto portfolio.

Now computes all combined metrics directly from the trade list:
- PF: sum(wins) / sum(losses) from actual trade PnLs
- MDD: portfolio equity curve from time-sorted trades
- Win rate: direct count from trade PnLs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:12:42 +09:00

342 lines
13 KiB
Python

import pytest
from unittest.mock import patch, MagicMock
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
def test_fetch_latest_data_calls_subprocess():
"""fetch_latest_data가 심볼별로 fetch_history.py를 호출하는지 확인."""
from scripts.weekly_report import fetch_latest_data
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0)
fetch_latest_data(["XRPUSDT", "TRXUSDT"], days=35)
assert mock_run.call_count == 2
args_0 = mock_run.call_args_list[0][0][0]
assert "--symbol" in args_0
assert "XRPUSDT" in args_0
assert "--days" in args_0
assert "35" in args_0
def test_run_backtest_returns_summary():
"""run_backtest가 WF 백테스트를 실행하고 결과를 반환하는지 확인."""
from scripts.weekly_report import run_backtest
mock_result = {
"summary": {
"total_trades": 27, "total_pnl": 217.0, "return_pct": 21.7,
"win_rate": 66.7, "profit_factor": 1.57, "max_drawdown_pct": 12.0,
"sharpe_ratio": 33.3, "avg_win": 20.0, "avg_loss": -10.0,
"total_fees": 5.0, "close_reasons": {},
},
"folds": [], "trades": [],
}
with patch("scripts.weekly_report.WalkForwardBacktester") as MockWF:
MockWF.return_value.run.return_value = mock_result
result = run_backtest(
symbols=["XRPUSDT"], train_months=3, test_months=1,
params={"atr_sl_mult": 2.0, "atr_tp_mult": 2.0,
"signal_threshold": 3, "adx_threshold": 25,
"volume_multiplier": 2.5},
)
assert result["summary"]["profit_factor"] == 1.57
assert result["summary"]["total_trades"] == 27
def test_fetch_live_trades_from_api():
"""대시보드 API에서 청산 트레이드를 가져오는지 확인."""
from scripts.weekly_report import fetch_live_trades
mock_response = MagicMock()
mock_response.json.return_value = {
"trades": [
{"symbol": "XRPUSDT", "direction": "LONG", "net_pnl": 19.568,
"commission": 0.216, "status": "CLOSED"},
],
"total": 1,
}
mock_response.raise_for_status = MagicMock()
with patch("scripts.weekly_report.httpx.get", return_value=mock_response):
trades = fetch_live_trades("http://test:8000")
assert len(trades) == 1
assert trades[0]["symbol"] == "XRPUSDT"
assert trades[0]["net_pnl"] == pytest.approx(19.568)
def test_fetch_live_trades_api_failure():
"""API 실패 시 빈 리스트 반환."""
from scripts.weekly_report import fetch_live_trades
with patch("scripts.weekly_report.httpx.get", side_effect=Exception("connection refused")):
trades = fetch_live_trades("http://unreachable:8000")
assert trades == []
def test_fetch_live_stats_from_api():
"""대시보드 API에서 전체 통계를 가져오는지 확인."""
from scripts.weekly_report import fetch_live_stats
mock_response = MagicMock()
mock_response.json.return_value = {
"total_trades": 15, "wins": 9, "losses": 6,
"total_pnl": 42.5, "total_fees": 3.2,
}
mock_response.raise_for_status = MagicMock()
with patch("scripts.weekly_report.httpx.get", return_value=mock_response):
stats = fetch_live_stats("http://test:8000")
assert stats["total_trades"] == 15
assert stats["wins"] == 9
import json
from datetime import date, timedelta
def test_load_trend_reads_previous_reports(tmp_path):
"""이전 주간 리포트를 읽어 PF/승률/MDD 추이를 반환."""
from scripts.weekly_report import load_trend
for i, (pf, wr, mdd) in enumerate([
(1.31, 48.0, 9.0), (1.24, 45.0, 11.0),
(1.20, 44.0, 12.0), (1.18, 43.0, 14.0),
]):
d = date(2026, 3, 7) - timedelta(weeks=3 - i)
report = {
"date": d.isoformat(),
"backtest": {"summary": {
"profit_factor": pf, "win_rate": wr, "max_drawdown_pct": mdd,
"total_trades": 20,
}},
}
(tmp_path / f"report_{d.isoformat()}.json").write_text(json.dumps(report))
trend = load_trend(str(tmp_path), weeks=4)
assert len(trend["pf"]) == 4
assert trend["pf"] == [1.31, 1.24, 1.20, 1.18]
assert trend["pf_declining_3w"] is True
def test_load_trend_empty_dir(tmp_path):
"""리포트가 없으면 빈 추이 반환."""
from scripts.weekly_report import load_trend
trend = load_trend(str(tmp_path), weeks=4)
assert trend["pf"] == []
assert trend["pf_declining_3w"] is False
def test_check_ml_trigger_all_met():
"""3개 조건 모두 충족 시 recommend=True."""
from scripts.weekly_report import check_ml_trigger
result = check_ml_trigger(
cumulative_trades=200, current_pf=0.85, pf_declining_3w=True,
)
assert result["recommend"] is True
assert result["met_count"] == 3
def test_check_ml_trigger_none_met():
"""조건 미충족 시 recommend=False."""
from scripts.weekly_report import check_ml_trigger
result = check_ml_trigger(
cumulative_trades=50, current_pf=1.5, pf_declining_3w=False,
)
assert result["recommend"] is False
assert result["met_count"] == 0
def test_run_degradation_sweep_returns_top_n():
"""스윕을 실행하고 PF 상위 N개 대안을 반환."""
from scripts.weekly_report import run_degradation_sweep
from unittest.mock import patch
fake_summaries = [
{"profit_factor": 1.15, "total_trades": 30, "total_pnl": 50, "return_pct": 5,
"win_rate": 55, "avg_win": 10, "avg_loss": -8, "max_drawdown_pct": 10,
"sharpe_ratio": 2.0, "total_fees": 1, "close_reasons": {}},
{"profit_factor": 1.08, "total_trades": 25, "total_pnl": 30, "return_pct": 3,
"win_rate": 50, "avg_win": 8, "avg_loss": -7, "max_drawdown_pct": 12,
"sharpe_ratio": 1.5, "total_fees": 1, "close_reasons": {}},
{"profit_factor": 0.95, "total_trades": 20, "total_pnl": -10, "return_pct": -1,
"win_rate": 40, "avg_win": 6, "avg_loss": -9, "max_drawdown_pct": 15,
"sharpe_ratio": 0.5, "total_fees": 1, "close_reasons": {}},
]
fake_combos = [
{"atr_sl_mult": 1.5}, {"atr_sl_mult": 1.0}, {"atr_sl_mult": 2.0},
]
with patch("scripts.weekly_report.run_single_backtest") as mock_bt:
mock_bt.side_effect = fake_summaries
with patch("scripts.weekly_report.generate_combinations", return_value=fake_combos):
alternatives = run_degradation_sweep(
symbols=["XRPUSDT"], train_months=3, test_months=1, top_n=3,
)
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")
def test_generate_report_orchestration(tmp_path):
"""generate_report가 모든 단계를 조합하여 리포트 dict를 반환."""
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": 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": mock_trades,
}
with patch("scripts.weekly_report.run_backtest", return_value=mock_bt_result):
with patch("scripts.weekly_report.fetch_live_stats", return_value={"total_trades": 0, "wins": 0, "total_pnl": 0}):
with patch("scripts.weekly_report.fetch_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),
report_date=date(2026, 3, 7),
)
assert report["date"] == "2026-03-07"
# 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이면 스윕 안 함
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"
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