"""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→SSE,0/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