feat(backtest): 回测流程集成基准对比—产出相对指标+时序json
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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_")
|
||||
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)"
|
||||
Reference in New Issue
Block a user