Files
sanguo_vnpy_v2/sanguo_backtest/cta_engine.py
T

267 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CTA strategy backtesting engine wrapper using vnpy_ctastrategy.backtesting."""
import sys
import os
import math
import traceback
import uuid
from datetime import datetime
from pathlib import Path
import pandas as pd
# Add vnpy source to path for local development
_VNPY_SRC = os.path.join(os.path.dirname(__file__), "..", "vnpy_v4.4.0")
_VNPY_SRC = os.path.abspath(_VNPY_SRC)
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)
class MockExchange:
SSE = "SSE" # Shanghai Stock Exchange
SZSE = "SZSE" # Shenzhen Stock Exchange
class Exchange:
SSE = "SSE"
SZSE = "SZSE"
def __init__(self, value):
self.value = value
def __repr__(self):
return f"Exchange.{self.value}"
Exchange = MockExchange.Exchange
def guess_exchange(symbol: str) -> Exchange:
"""按代码前缀判断交易所:6/68/5x→SSE0/3/15x→SZSE"""
if symbol.startswith(("60", "68", "51", "56", "58")):
return Exchange("SSE")
if symbol.startswith(("00", "30", "15")):
return Exchange("SZSE")
return Exchange("SSE")
def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end: str, cfg, db_path: str, benchmark: str = "hs300") -> BacktestResult:
"""
Run CTA strategy backtest using vnpy_ctastrategy BacktestingEngine.
Args:
strategy_class: CTA strategy class to backtest
symbol: Stock symbol (e.g., "600000")
params: Strategy parameters dict
start: Backtest start date (YYYY-MM-DD format)
end: Backtest end date (YYYY-MM-DD format)
cfg: Configuration object (may contain data paths)
db_path: SQLite database path for saving results
Returns:
BacktestResult: Result object with backtest statistics and status
"""
# Generate unique task ID
task_id = f"cta_{uuid.uuid4().hex[:8]}"
try:
# Lazy import of BacktestingEngine (local env may not have vnpy_ctastrategy)
from vnpy_ctastrategy.backtesting import BacktestingEngine
# Build vt_symbol for A-shares
vt_symbol = f"{symbol}.{guess_exchange(symbol).value}"
# Convert date strings to datetime objects
start_dt = datetime.strptime(start, "%Y-%m-%d")
end_dt = datetime.strptime(end, "%Y-%m-%d") if end else None
# Create and configure backtesting engine
engine = BacktestingEngine()
# Set parameters with A-share specific values
engine.set_parameters(
vt_symbol=vt_symbol,
interval="d", # Interval.DAILY.value — vnpy enum uses "d" not "1d"
start=start_dt,
end=end_dt,
rate=0.001, # Commission rate (0.1% for A-shares)
slippage=0, # No slippage for simplicity
size=1, # Contract size (1 for stocks)
pricetick=0.01, # Minimum price tick (0.01 yuan for A-shares)
capital=1_000_000 # Starting capital — 0 causes instant liquidation on first trade
)
# Add strategy
engine.add_strategy(strategy_class, params)
# Configure vnpy DB → A-share quant_trading.db. Worker process (spawn)
# doesn't inherit main-process SETTINGS, so set before engine.load_data.
# _dcfg is also reused by the metrics branch (benchmark data_paths) since the
# cfg param can be None when called via the API.
_dcfg = None
try:
from vnpy.trader.setting import SETTINGS
from sanguo_data.config import load_config, find_config_path
_dcfg = load_config(find_config_path())
SETTINGS["database.name"] = "sqlite"
SETTINGS["database.database"] = _dcfg.data_paths["vnpy_db"]
except Exception:
pass
# Load historical data
engine.load_data()
# Run backtesting
engine.run_backtesting()
# Calculate statistics — calculate_result() returns a daily DataFrame,
# calculate_statistics(df) returns the stats dict (sharpe/drawdown/etc.)
daily_df = engine.calculate_result()
raw_stats = engine.calculate_statistics(daily_df, output=False) or {}
# Ensure JSON-serializable (vnpy may include Timestamp / non-numeric / NaN values)
statistics = {
k: (None if (isinstance(v, float) and not math.isfinite(v))
else v if isinstance(v, (int, float, str, bool)) or v is None
else str(v))
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 code (default hs300) + a cfg that has data_paths.
# _dcfg is the loaded config; fall back to the passed cfg if loading failed.
benchmark_code = BENCHMARK_SYMBOL.get(benchmark, "sh000300")
bench_cfg = _dcfg if (_dcfg is not None and hasattr(_dcfg, "data_paths")) else cfg
# 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, bench_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": [None if (isinstance(x, float) and not math.isfinite(x)) else x
for x in 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.
if daily_df is not None and hasattr(daily_df, "empty") and not daily_df.empty:
if "balance" in daily_df.columns:
_bal = daily_df["balance"].astype(float)
elif "net_pnl" in daily_df.columns:
_bal = daily_df["net_pnl"].astype(float).cumsum() + 1_000_000
else:
_bal = None
equity_df = pd.DataFrame({
"date": daily_df.index.astype(str),
"balance": _bal.tolist(),
}) if _bal is not None else pd.DataFrame()
else:
equity_df = pd.DataFrame()
# Build trades DataFrame (S1.2): engine.trades is dict[vt_tradeid, TradeData].
trades_dict = engine.trades if isinstance(engine.trades, dict) else {}
trades_df = pd.DataFrame([
{
"datetime": str(t.datetime),
"direction": str(t.direction),
"offset": str(t.offset),
"price": t.price,
"volume": t.volume,
"vt_symbol": getattr(t, "vt_symbol", ""),
}
for t in trades_dict.values()
])
# Build result object
result = BacktestResult(
task_id=task_id,
type="cta",
status="done",
strategy=strategy_class.__name__,
symbol=symbol,
params=params,
start=start,
end=end,
statistics=statistics,
equity_curve=equity_df,
trades=trades_df,
)
except Exception as e:
# Handle any exceptions and return failed result
error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
result = BacktestResult(
task_id=task_id,
type="cta",
status="failed",
strategy=strategy_class.__name__,
symbol=symbol,
params=params,
start=start,
end=end,
statistics={},
equity_curve=None,
trades=None,
error_msg=error_msg
)
# Save result to database. file_dir = db dir so equity_curve/trades persist
# to parquet (S1.1) and reload via result.id.
save_result(result, db_path=db_path, file_dir=os.path.dirname(os.path.abspath(db_path)))
return result
# Module-level reference for mocking in tests
BacktestingEngine = None # Will be set when imported inside run_cta_backtest