From b9197a88893c5f81651fb84655cc3c1cb64f6f5b Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sat, 11 Jul 2026 13:41:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(backtest):=20=E5=9B=9E=E6=B5=8B=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E9=9B=86=E6=88=90=E5=9F=BA=E5=87=86=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E2=80=94=E4=BA=A7=E5=87=BA=E7=9B=B8=E5=AF=B9=E6=8C=87=E6=A0=87?= =?UTF-8?q?+=E6=97=B6=E5=BA=8Fjson?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sanguo_backtest/cta_engine.py | 62 +++++++++++ tests/backtest/test_cta_engine.py | 171 +++++++++++++++++++++++++++++- 2 files changed, 232 insertions(+), 1 deletion(-) diff --git a/sanguo_backtest/cta_engine.py b/sanguo_backtest/cta_engine.py index add8b9a..910ddc7 100644 --- a/sanguo_backtest/cta_engine.py +++ b/sanguo_backtest/cta_engine.py @@ -15,6 +15,8 @@ if _VNPY_SRC not in sys.path: sys.path.insert(0, _VNPY_SRC) from sanguo_backtest.result_store import BacktestResult, save_result +from sanguo_data.datareader import read_index_daily +from sanguo_backtest.metrics import compute_metrics, BENCHMARK_SYMBOL # Mock Exchange enum for local use (replaces vnpy.trader.constant.Exchange) @@ -121,6 +123,66 @@ def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end: for k, v in raw_stats.items() } + # Calculate relative metrics against benchmark (Task 3) + # Ensure daily_df index is datetime for compute_metrics + if daily_df is not None and not daily_df.empty: + if not isinstance(daily_df.index, pd.DatetimeIndex): + daily_df.index = pd.to_datetime(daily_df.index) + + # Get benchmark config (default hs300) + benchmark_key = getattr(cfg, "benchmark", "hs300") if hasattr(cfg, "benchmark") else "hs300" + benchmark_code = BENCHMARK_SYMBOL.get(benchmark_key, "sh000300") + + # Load benchmark data + start_date = start_dt if isinstance(start_dt, datetime) else datetime.strptime(start, "%Y-%m-%d") + end_date = end_dt if isinstance(end_dt, datetime) else datetime.strptime(end, "%Y-%m-%d") + + try: + bench_df = read_index_daily(benchmark_code, start_date, end_date, cfg) + if bench_df is not None and not bench_df.empty and "close" in bench_df.columns: + # Calculate benchmark daily returns + bench_df["date"] = pd.to_datetime(bench_df["date"]) + bench_df = bench_df.sort_values("date") + benchmark_returns = bench_df["close"].pct_change().dropna() + benchmark_returns.index = pd.to_datetime(bench_df["date"].iloc[1:]) + + # vnpy daily_df must have "return" column for compute_metrics + # If not present, calculate from balance + if "return" not in daily_df.columns: + if "balance" in daily_df.columns: + daily_df["return"] = daily_df["balance"].pct_change().fillna(0) + elif "net_pnl" in daily_df.columns: + daily_df["return"] = (daily_df["net_pnl"] / 1_000_000).fillna(0) + else: + daily_df["return"] = 0.0 + + # Compute relative metrics + metrics_result = compute_metrics(daily_df, benchmark_returns) + + # Merge scalars into statistics (for API response) + statistics.update(metrics_result.scalars) + + # Serialize series to JSON (separate file, same as equity_curve/trades) + import json + series_data = {} + for key, series in metrics_result.series.items(): + if isinstance(series, pd.Series): + series_data[key] = { + "dates": series.index.astype(str).tolist(), + "values": series.tolist() + } + + # Write metrics series to JSON file + file_dir = os.path.dirname(os.path.abspath(db_path)) + metrics_file = os.path.join(file_dir, f"{task_id}_metrics.json") + with open(metrics_file, "w") as f: + json.dump({"series": series_data}, f, indent=2) + + except Exception as metrics_error: + # Log but don't fail backtest if metrics calculation fails + import logging + logging.warning(f"Failed to compute relative metrics: {metrics_error}") + # Build equity curve DataFrame (S1.2): use the daily_df returned by # calculate_result (index=date, has a 'balance' column). get_all_daily_results # returns DailyResult objects (not dicts), so prefer daily_df. diff --git a/tests/backtest/test_cta_engine.py b/tests/backtest/test_cta_engine.py index 4c69e2f..468de11 100644 --- a/tests/backtest/test_cta_engine.py +++ b/tests/backtest/test_cta_engine.py @@ -1,7 +1,22 @@ """Tests for sanguo_backtest.cta_engine module.""" +# Mock vnpy and tzlocal modules before importing anything that depends on them +import sys +from unittest.mock import MagicMock +mock_tzlocal = MagicMock() +mock_tzlocal.get_localzone_name = MagicMock(return_value="UTC") +sys.modules["tzlocal"] = mock_tzlocal +sys.modules["vnpy.trader.setting"] = MagicMock() +sys.modules["vnpy.trader.constant"] = MagicMock() +sys.modules["vnpy.trader.object"] = MagicMock() +sys.modules["vnpy.trader.database"] = MagicMock() +sys.modules["vnpy_ctastrategy.backtesting"] = MagicMock() +sys.modules["empyrical"] = MagicMock() + import pytest +import json from unittest.mock import Mock, patch, MagicMock from datetime import datetime +from pathlib import Path import pandas as pd from sanguo_backtest.cta_engine import run_cta_backtest @@ -148,4 +163,158 @@ class TestRunCtaBacktest: # Verify unique task IDs assert result1.task_id != result2.task_id assert result1.task_id.startswith("cta_") - assert result2.task_id.startswith("cta_") \ No newline at end of file + assert result2.task_id.startswith("cta_") + + def test_run_cta_backtest_computes_relative_metrics(self, temp_db_path): + """Test that run_cta_backtest computes relative metrics against benchmark.""" + # Mock strategy class + mock_strategy_class = Mock() + mock_strategy_class.__name__ = "BmTestStrategy" + + # Mock daily_df with 'return' column (required by compute_metrics) + dates = pd.date_range("2024-01-01", "2024-03-31", freq="D") + daily_df = pd.DataFrame({ + "return": [0.001] * len(dates) + }, index=dates) + + # Mock vnpy BacktestingEngine + mock_engine = MagicMock() + mock_engine.calculate_result.return_value = daily_df + mock_engine.calculate_statistics.return_value = { + "total_return": 0.15, + "sharpe_ratio": 1.2, + "max_drawdown": -0.08, + } + + # Mock config with benchmark + mock_cfg = Mock() + mock_cfg.data_paths = {"daily_dir": "/mock/daily_dir"} + + # Mock read_index_daily to return benchmark data + mock_bench_df = pd.DataFrame({ + "date": dates, + "close": [100.0] * len(dates) + }) + + # Mock compute_metrics result + mock_metrics_result = Mock() + mock_metrics_result.scalars = { + "alpha": 0.05, + "beta": 0.95, + "sharpe_ratio": 1.3, + "total_return": 0.15, + "benchmark_return": 0.10 + } + mock_metrics_result.series = { + "equity_curve": pd.Series([1.0, 1.1, 1.2]), + "benchmark_curve": pd.Series([1.0, 1.05, 1.1]), + "alpha": pd.Series([0.01, 0.02, 0.03]), + "beta": pd.Series([0.9, 0.95, 1.0]), + "drawdown": pd.Series([0.0, -0.01, -0.02]) + } + + # Create mock module with BacktestingEngine + mock_module = MagicMock() + mock_module.BacktestingEngine = Mock(return_value=mock_engine) + + # Mock tzlocal and vnpy modules to avoid import errors + mock_tzlocal = MagicMock() + mock_tzlocal.get_localzone_name = Mock(return_value="UTC") + + with patch.dict("sys.modules", { + "vnpy_ctastrategy.backtesting": mock_module, + "tzlocal": mock_tzlocal, + "vnpy.trader.setting": MagicMock() + }): + with patch("sanguo_backtest.cta_engine.read_index_daily", return_value=mock_bench_df): + with patch("sanguo_backtest.cta_engine.compute_metrics", return_value=mock_metrics_result): + result = run_cta_backtest( + strategy_class=mock_strategy_class, + symbol="600000", + params={"window": 20}, + start="2024-01-01", + end="2024-03-31", + cfg=mock_cfg, + db_path=temp_db_path + ) + + # Verify result contains relative metrics (scalars merged into statistics) + assert result.statistics.get("alpha") == 0.05 + assert result.statistics.get("beta") == 0.95 + assert result.statistics.get("sharpe_ratio") == 1.3 # Should be present + assert result.statistics.get("total_return") == 0.15 # Should be present + + # Verify metrics JSON file was written + file_dir = Path(temp_db_path).parent + metrics_file = file_dir / f"{result.task_id}_metrics.json" + assert metrics_file.exists(), f"Metrics file not found: {metrics_file}" + + # Verify metrics file can be loaded and contains expected keys + with open(metrics_file, "r") as f: + metrics_data = json.load(f) + + # Check that we have 5 series keys + series_keys = list(metrics_data.get("series", {}).keys()) + assert len(series_keys) == 5 + assert "equity_curve" in series_keys + assert "benchmark_curve" in series_keys + assert "alpha" in series_keys + assert "beta" in series_keys + assert "drawdown" in series_keys + + def test_run_cta_backtest_default_benchmark_hs300(self, temp_db_path): + """Test that default benchmark is hs300 when not specified in config.""" + mock_strategy_class = Mock() + mock_strategy_class.__name__ = "DefaultBmStrategy" + + # Mock daily_df + dates = pd.date_range("2024-01-01", "2024-03-31", freq="D") + daily_df = pd.DataFrame({ + "return": [0.001] * len(dates) + }, index=dates) + + mock_engine = MagicMock() + mock_engine.calculate_result.return_value = daily_df + mock_engine.calculate_statistics.return_value = {"total_return": 0.15} + + # Mock config without benchmark (should use default hs300) + mock_cfg = Mock() + mock_cfg.data_paths = {"daily_dir": "/mock/daily_dir"} + + mock_bench_df = pd.DataFrame({ + "date": dates, + "close": [100.0] * len(dates) + }) + + mock_metrics_result = Mock() + mock_metrics_result.scalars = {"alpha": 0.05} + mock_metrics_result.series = {} + + mock_module = MagicMock() + mock_module.BacktestingEngine = Mock(return_value=mock_engine) + + # Mock tzlocal and vnpy modules to avoid import errors + mock_tzlocal = MagicMock() + mock_tzlocal.get_localzone_name = Mock(return_value="UTC") + + with patch.dict("sys.modules", { + "vnpy_ctastrategy.backtesting": mock_module, + "tzlocal": mock_tzlocal, + "vnpy.trader.setting": MagicMock() + }): + with patch("sanguo_backtest.cta_engine.read_index_daily", return_value=mock_bench_df) as mock_read: + with patch("sanguo_backtest.cta_engine.compute_metrics", return_value=mock_metrics_result): + result = run_cta_backtest( + strategy_class=mock_strategy_class, + symbol="600000", + params={}, + start="2024-01-01", + end="2024-03-31", + cfg=mock_cfg, + db_path=temp_db_path + ) + + # Verify read_index_daily was called with hs300 code (sh000300) + mock_read.assert_called_once() + call_args = mock_read.call_args + assert call_args[0][0] == "sh000300", "Default benchmark should be hs300 (sh000300)" \ No newline at end of file