7fe3fb0844
根因: empyrical 0.5.5 引用 numpy2.0 已移除的 np.NINF → compute_metrics 静默崩 → _metrics.json 不生成 → 结果页回退 vnpy 原始字段(单位混乱: total_return当百分数、max_drawdown当元 → 前端×100显 3305%/-50M%)。 - metrics.py: 导入 empyrical 前补回 np 别名(NINF/Inf/PINF/NaN/NAN/infty) - routes.py: benchmark-curve/risk-series 缺 metrics 文件时返空200(不再404拖垮整页); get_result 从 statistics 抽 relative_metrics - cta_engine.py: bench_df 日期 strip tz 防 pct_change 崩; metrics 块加 traceback 日志 - Result.vue: onMounted 用 Promise.allSettled 隔离7端点, 单接口失败不拖垮整页 - result_store.py: _safe_read_json 容错迁移后残留 NAS 绝对路径, stale path 不崩 list_results - datareader.py: read_index_daily 改从 vnpy DB 读 + 前缀解析交易所(sh→SSE, 避免 000300 被 guess_exchange 误判 SZSE)
374 lines
16 KiB
Python
374 lines
16 KiB
Python
"""CTA strategy backtesting engine wrapper using vnpy_ctastrategy.backtesting."""
|
||
import sys
|
||
import os
|
||
import math
|
||
import logging
|
||
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
|
||
|
||
|
||
class _Tee:
|
||
"""同时写多个流(用于把引擎 stdout 落盘到 per-task 日志)。"""
|
||
|
||
def __init__(self, *streams):
|
||
self.streams = streams
|
||
|
||
def write(self, data):
|
||
for s in self.streams:
|
||
s.write(data)
|
||
|
||
def flush(self):
|
||
for s in self.streams:
|
||
try:
|
||
s.flush()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# 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",
|
||
task_id: str | None = None,
|
||
capital: float = 1_000_000,
|
||
position_pct: float = 0.95,
|
||
commission_rate: float = 0.00025,
|
||
min_commission: float = 5.0,
|
||
stamp_duty_rate: float = 0.0005,
|
||
transfer_fee_rate: float = 0.00001,
|
||
) -> BacktestResult:
|
||
"""
|
||
Run CTA strategy backtest using AShareBacktestingEngine (vnpy 子类化).
|
||
|
||
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
|
||
benchmark: Benchmark code (hs300/zz500)
|
||
task_id: Optional task ID from runner (reused as the persisted task_id so
|
||
runner-id == DB task_id; if omitted a fresh uuid is generated).
|
||
capital: Starting capital (元)
|
||
position_pct: 仓位占比 0~1(定寸用:shares = floor(capital*pct/price/100)*100)
|
||
commission_rate: A 股佣金率双边(默认万 2.5)
|
||
min_commission: 单笔最低佣金(默认 5 元)
|
||
stamp_duty_rate: 印花税率卖方(默认 0.0005)
|
||
transfer_fee_rate: 过户费率沪市(默认 0.00001)
|
||
|
||
Returns:
|
||
BacktestResult: Result object with backtest statistics and status
|
||
"""
|
||
# Use runner-provided task_id (durable, single id across pool/DB/URL) or generate
|
||
if not task_id:
|
||
task_id = f"cta_{uuid.uuid4().hex[:8]}"
|
||
|
||
try:
|
||
# Lazy import of AShareBacktestingEngine (subclass of vnpy BacktestingEngine)
|
||
from sanguo_backtest.ashare_engine import AShareBacktestingEngine
|
||
|
||
# Build vt_symbol for A-shares
|
||
_exchange = guess_exchange(symbol)
|
||
vt_symbol = f"{symbol}.{_exchange.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 A-share backtesting engine
|
||
engine = AShareBacktestingEngine()
|
||
|
||
# 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=commission_rate, # 佣金率(AShareDailyResult 用自身 commission_rate,此处仅保持一致)
|
||
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=capital, # Starting capital
|
||
)
|
||
|
||
# A 股适配参数(定寸 + 费用)
|
||
engine.position_pct = position_pct
|
||
engine.commission_rate = commission_rate
|
||
engine.min_commission = min_commission
|
||
engine.stamp_duty_rate = stamp_duty_rate
|
||
engine.transfer_fee_rate = transfer_fee_rate
|
||
engine.is_sse = (_exchange.value == "SSE")
|
||
|
||
# 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 as e:
|
||
logging.warning("vnpy 数据库配置加载失败(回测可能无法加载历史数据): %s", e)
|
||
|
||
# Capture engine run output (load/run/stats) to per-task log file, so the
|
||
# result page 日志 tab has real content. Tee stdout inside the worker process
|
||
# (contained — doesn't affect the main API process).
|
||
file_dir = os.path.dirname(os.path.abspath(db_path))
|
||
log_path = os.path.join(file_dir, f"{task_id}.log")
|
||
_log_f = open(log_path, "w", encoding="utf-8")
|
||
_log_f.write(
|
||
f"==== 回测日志 ====\n任务: {task_id}\n策略: {getattr(strategy_class, '__name__', strategy_class)}\n"
|
||
f"标的: {vt_symbol}\n区间: {start} ~ {end}\n参数: {params}\n基准: {benchmark}\n==================\n"
|
||
)
|
||
_log_f.flush()
|
||
_orig_stdout = sys.stdout
|
||
sys.stdout = _Tee(_orig_stdout, _log_f)
|
||
try:
|
||
# Load historical data
|
||
engine.load_data()
|
||
|
||
# C1 定寸:size = N 股/手。load_data 后取首根 bar close 算满仓手数。
|
||
# vnpy 的 turnover/PnL/commission 自动 ×size,策略 volume 保持 1 手=N 股=满仓,
|
||
# 所有策略(DoubleMa/BollChannel/DualThrust)无需改。
|
||
_sizing_shares_per_lot = 0
|
||
if engine.history_data:
|
||
_first_close = engine.history_data[0].close_price
|
||
if _first_close > 0:
|
||
_sizing_shares_per_lot = int(
|
||
math.floor(capital * position_pct / _first_close / 100) * 100
|
||
)
|
||
engine.size = _sizing_shares_per_lot
|
||
logging.info(
|
||
"定寸: N=%s 股/手 (capital=%s pct=%s first_close=%s)",
|
||
_sizing_shares_per_lot, capital, position_pct, _first_close,
|
||
)
|
||
|
||
# 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 {}
|
||
finally:
|
||
sys.stdout = _orig_stdout
|
||
_log_f.flush()
|
||
_log_f.close()
|
||
# 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()
|
||
}
|
||
|
||
# H7 degenerate 检测:零成交或空数据不静默 done
|
||
_degenerate_reason = None
|
||
trades_dict_check = engine.trades if isinstance(engine.trades, dict) else {}
|
||
if not trades_dict_check:
|
||
_degenerate_reason = "零成交记录(策略未触发任何交易信号)"
|
||
elif daily_df is None or (hasattr(daily_df, "empty") and daily_df.empty):
|
||
_degenerate_reason = "日度盈亏数据为空"
|
||
if _degenerate_reason:
|
||
# 标 degenerate flag(status 仍为 done,避免前端 status map 不认导致列表/计数异常;
|
||
# 结果页据 trades=0 + statistics.degenerate 自然呈现"零成交"诚实状态)
|
||
statistics["degenerate"] = True
|
||
statistics["degenerate_reason"] = _degenerate_reason
|
||
logging.warning("回测退化: %s (symbol=%s strategy=%s)", _degenerate_reason, symbol, strategy_class.__name__)
|
||
|
||
# C1: 暴露定寸参数到结果(集成测试 + 前端可查)
|
||
statistics["sizing_shares_per_lot"] = _sizing_shares_per_lot
|
||
|
||
# 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"])
|
||
# 去时区:daily_df.index 是 tz-naive,benchmark 若 tz-aware 会让
|
||
# compute_metrics 内部 reindex 抛 TypeError(被上层 except 静默吞掉,
|
||
# 致 _metrics.json 不生成)。统一去掉 tz 保证对齐。
|
||
if getattr(bench_df["date"].dt, "tz", None) is not None:
|
||
bench_df["date"] = bench_df["date"].dt.tz_localize(None)
|
||
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:])
|
||
|
||
# H4: compute_metrics 内部从 daily_df["balance"] 自算 simple return,
|
||
# 不再依赖 vnpy 的 log return 列(删原三路 fallback)
|
||
|
||
# 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.
|
||
# 附 traceback 以便定位(_metrics.json 不生成时这里是根因)。
|
||
logging.warning(
|
||
"相对指标计算失败(回测结果不受影响): %s\n%s",
|
||
metrics_error, traceback.format_exc(),
|
||
)
|
||
|
||
# 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() + capital
|
||
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 — H7: degenerate 走 status="done" + statistics.degenerate flag
|
||
# (不发明新 status 值,前端 status map 只认 done/failed/running/pending)
|
||
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 |