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