feat(backtest): 支持5m/15m周期回测(ashare适配绕vnpy Interval enum)

ashare_engine override load_data: 5m/15m 直查 dbbardata 转 BarData(MINUTE),
绕过 vnpy Interval enum 限制(原生只认 d/1h/1m)。cta_engine +interval 参数,
5m/15m 映射 MINUTE 过父类校验。api(schema/routes)+orchestrator 透传 interval。
顺带修 cta_engine DB路径污染(yaml NAS路径在Win VPS误解析→改读vt_setting.json)。

测试: 15m 600519一年 3888bars total_return-0.22 sharpe-2.04; 5m 11664bars; 日线未回归(237bars)。
This commit is contained in:
2026-07-18 18:45:24 +08:00
parent d6162d1928
commit 43b6a58ba7
5 changed files with 165 additions and 8 deletions
+2 -1
View File
@@ -78,7 +78,8 @@ async def submit_cta(req: CtaBacktestRequest):
cfg=None, cfg=None,
benchmark=req.benchmark, benchmark=req.benchmark,
capital=req.capital, capital=req.capital,
position_pct=req.position_pct position_pct=req.position_pct,
interval=req.interval,
) )
return {"task_id": tid} return {"task_id": tid}
+2
View File
@@ -14,6 +14,8 @@ class CtaBacktestRequest(BaseModel):
benchmark: str = "hs300" benchmark: str = "hs300"
capital: float = 1_000_000 capital: float = 1_000_000
position_pct: float = 0.95 position_pct: float = 0.95
# K 线周期:"d"=日线(默认) / "5m" / "15m"。5m/15m 走 AShareBacktestingEngine 适配。
interval: str = "d"
# A 股费用参数(可选,前端先不暴露,给默认值) # A 股费用参数(可选,前端先不暴露,给默认值)
commission_rate: float = 0.00025 # 万 2.5 commission_rate: float = 0.00025 # 万 2.5
min_commission: float = 5.0 # 最低 5 元 min_commission: float = 5.0 # 最低 5 元
+98 -1
View File
@@ -6,13 +6,15 @@ vnpy 源码零修改,全部覆写在本文件。
- AShareBacktestingEngine - AShareBacktestingEngine
- send_order 覆写 → 做空拦截(SHORT+OPEN 拒单)+ 定寸重算 volume - send_order 覆写 → 做空拦截(SHORT+OPEN 拒单)+ 定寸重算 volume
- update_daily_close 覆写 → 工厂换 AShareDailyResult(父类在 :647 实例化 DailyResult - update_daily_close 覆写 → 工厂换 AShareDailyResult(父类在 :647 实例化 DailyResult
- load_data 覆写 → 支持 5m/15m 周期(vnpy Interval enum 不认 '5m'/'15m'
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime
from vnpy_ctastrategy.backtesting import BacktestingEngine, DailyResult from vnpy_ctastrategy.backtesting import BacktestingEngine, DailyResult
from vnpy.trader.constant import Direction, Offset from vnpy.trader.constant import Direction, Offset, Interval, Exchange
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -123,6 +125,11 @@ class AShareBacktestingEngine(BacktestingEngine):
self.stamp_duty_rate: float = 0.0005 # 卖方 0.05% self.stamp_duty_rate: float = 0.0005 # 卖方 0.05%
self.transfer_fee_rate: float = 0.00001 # 沪市 0.001% self.transfer_fee_rate: float = 0.00001 # 沪市 0.001%
self.is_sse: bool = False self.is_sse: bool = False
# 5m/15m 适配:vnpy Interval enum 不认 '5m'/'15m'cta_engine 把 engine.interval
# 映射成 Interval.MINUTE 让父类 set_parameters 校验通过,真实 DB interval 字符串
# 存此字段供 load_data 自定义路径使用。默认 "d" 走 vnpy 原生日线路径。
self.raw_interval: str = "d"
self.sqlite_db_path: str | None = None # 由 cta_engine 注入(_dcfg.data_paths["vnpy_db"]
def send_order( def send_order(
self, self,
@@ -174,3 +181,93 @@ class AShareBacktestingEngine(BacktestingEngine):
transfer_fee_rate=self.transfer_fee_rate, transfer_fee_rate=self.transfer_fee_rate,
is_sse=self.is_sse, is_sse=self.is_sse,
) )
def load_data(self) -> None:
"""覆写父类:支持 5m/15m 周期(绕过 vnpy Interval enum 限制)。
vnpy Interval enum 只有 MINUTE('1m')/HOUR('1h')/DAILY('d') 等,不认 '5m'/'15m'
父类 load_data 调 ``INTERVAL_DELTA_MAP[self.interval]`` 和
``load_bar_data(..., self.interval, ...)`` 都依赖 enum,传 '15m' 会 ValueError。
适配思路:self.interval 已被父类 set_parameters 映射成 Interval.MINUTEenum
校验通过),真实 DB interval 存 self.raw_interval。当 raw_interval 是 5m/15m
时,直接 sqlite 查 dbbardata 表,把每行转 BarData(interval=Interval.MINUTE)
绕过 vnpy database_manager 的 enum 限制;其余周期(d/1m 等)走 vnpy 原生路径。
"""
if self.raw_interval not in ("5m", "15m"):
super().load_data()
return
self._load_intraday_data()
def _load_intraday_data(self) -> None:
"""5m/15m 直查 SQLite → BarData(MINUTE),绕过 vnpy enum 限制。"""
from vnpy.trader.object import BarData
import sqlite3
self.output(f"开始加载 {self.raw_interval} 历史数据(ashare 适配)")
if not self.end:
self.end = datetime.now()
if self.start >= self.end:
self.output("起始日期必须小于结束日期")
return
db_path = self.sqlite_db_path
if not db_path:
try:
from vnpy.trader.setting import SETTINGS
db_path = SETTINGS.get("database.database")
except Exception:
db_path = None
if not db_path:
raise RuntimeError(
"5m/15m 回测需要 sqlite_db_path(或 SETTINGS['database.database']),"
"cta_engine 应在 load_data 前注入。"
)
symbol, exchange_str = self.vt_symbol.split(".")
# peewee DateTimeField 存的是 ISO 字符串;start/end 用 datetime 比较即可
# (SQLite 会把参数转成可比较的字符串形式)。
conn = sqlite3.connect(db_path)
try:
cur = conn.execute(
"SELECT datetime, volume, turnover, open_interest, "
"open_price, high_price, low_price, close_price "
"FROM dbbardata "
"WHERE symbol=? AND exchange=? AND interval=? "
"AND datetime>=? AND datetime<=? "
"ORDER BY datetime",
(symbol, exchange_str, self.raw_interval, self.start, self.end),
)
rows = cur.fetchall()
finally:
conn.close()
exchange = Exchange(exchange_str)
bars: list[BarData] = []
for dt, vol, turnover, oi, o, h, l, c in rows:
if isinstance(dt, str):
try:
dt = datetime.fromisoformat(dt)
except ValueError:
continue
# vnpy_sqlite save 时 convert_tz 改成 UTC,回测时按本地时间跑即可
# (日线回测也是直接读 DB datetime,行为一致)。
bars.append(BarData(
symbol=symbol,
exchange=exchange,
datetime=dt,
interval=Interval.MINUTE, # 5m/15m 不在 enum,统一标 MINUTE
volume=float(vol or 0),
turnover=float(turnover or 0),
open_interest=float(oi or 0),
open_price=float(o or 0),
high_price=float(h or 0),
low_price=float(l or 0),
close_price=float(c or 0),
gateway_name="sqlite",
))
self.history_data.clear()
self.history_data.extend(bars)
self.output(f"历史数据加载完成,数据量:{len(self.history_data)}")
+57 -2
View File
@@ -67,6 +67,27 @@ def guess_exchange(symbol: str) -> Exchange:
return Exchange("SSE") return Exchange("SSE")
def _read_vt_setting_db() -> str | None:
"""从 vnpy 原生 vt_setting.json 直接读 database.database 路径。
vnpy SETTINGS 字典会被 datareader.read_index_daily 等代码覆写为 yaml 的
NAS Linux 路径(Windows VPS 上不存在但有 0 字节空文件,os.path.exists 误判),
导致连续多次回测时第二次起路径污染。vt_setting.json 是机器本地配置,权威。
"""
try:
import json
from pathlib import Path
# vnpy 约定:~/.vntrader/vt_setting.json
home = Path.home() / ".vntrader" / "vt_setting.json"
if not home.exists():
return None
with open(home, "r", encoding="utf-8") as f:
data = json.load(f)
return data.get("database.database")
except Exception:
return None
def run_cta_backtest( def run_cta_backtest(
strategy_class, strategy_class,
symbol: str, symbol: str,
@@ -83,6 +104,7 @@ def run_cta_backtest(
min_commission: float = 5.0, min_commission: float = 5.0,
stamp_duty_rate: float = 0.0005, stamp_duty_rate: float = 0.0005,
transfer_fee_rate: float = 0.00001, transfer_fee_rate: float = 0.00001,
interval: str = "d",
) -> BacktestResult: ) -> BacktestResult:
""" """
Run CTA strategy backtest using AShareBacktestingEngine (vnpy 子类化). Run CTA strategy backtest using AShareBacktestingEngine (vnpy 子类化).
@@ -104,6 +126,9 @@ def run_cta_backtest(
min_commission: 单笔最低佣金(默认 5 元) min_commission: 单笔最低佣金(默认 5 元)
stamp_duty_rate: 印花税率卖方(默认 0.0005) stamp_duty_rate: 印花税率卖方(默认 0.0005)
transfer_fee_rate: 过户费率沪市(默认 0.00001) transfer_fee_rate: 过户费率沪市(默认 0.00001)
interval: K 线周期,"d"=日线(默认) / "5m" / "15m"
5m/15m 走 AShareBacktestingEngine 适配层(直查 dbbardata interval='5m'/'15m'
绕过 vnpy Interval enum 不认非标周期的限制)。
Returns: Returns:
BacktestResult: Result object with backtest statistics and status BacktestResult: Result object with backtest statistics and status
@@ -127,10 +152,18 @@ def run_cta_backtest(
# Create and configure A-share backtesting engine # Create and configure A-share backtesting engine
engine = AShareBacktestingEngine() engine = AShareBacktestingEngine()
# 周期映射:vnpy Interval enum 只有 '1m'/'1h'/'d' 等,不认 '5m'/'15m'。
# 5m/15m 时把 enum 传 MINUTE 让父类 set_parameters 校验通过,真实 DB interval
# 字符串存 engine.raw_interval 给 AShareBacktestingEngine.load_data 自定义路径用。
if interval in ("5m", "15m"):
engine_interval = "1m" # Interval.MINUTE.value
else:
engine_interval = interval # "d" / "1m" / "1h" 等 vnpy 原生支持的值
# Set parameters with A-share specific values # Set parameters with A-share specific values
engine.set_parameters( engine.set_parameters(
vt_symbol=vt_symbol, vt_symbol=vt_symbol,
interval="d", # Interval.DAILY.value — vnpy enum uses "d" not "1d" interval=engine_interval,
start=start_dt, start=start_dt,
end=end_dt, end=end_dt,
rate=commission_rate, # 佣金率(AShareDailyResult 用自身 commission_rate,此处仅保持一致) rate=commission_rate, # 佣金率(AShareDailyResult 用自身 commission_rate,此处仅保持一致)
@@ -139,6 +172,9 @@ def run_cta_backtest(
pricetick=0.01, # Minimum price tick (0.01 yuan for A-shares) pricetick=0.01, # Minimum price tick (0.01 yuan for A-shares)
capital=capital, # Starting capital capital=capital, # Starting capital
) )
# raw_interval 在 5m/15m 时触发 ashare_engine 适配路径;其它周期与 engine.interval
# 一致,走 vnpy 原生 load_data。
engine.raw_interval = interval
# A 股适配参数(定寸 + 费用) # A 股适配参数(定寸 + 费用)
engine.position_pct = position_pct engine.position_pct = position_pct
@@ -155,13 +191,32 @@ def run_cta_backtest(
# doesn't inherit main-process SETTINGS, so set before engine.load_data. # 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 # _dcfg is also reused by the metrics branch (benchmark data_paths) since the
# cfg param can be None when called via the API. # cfg param can be None when called via the API.
#
# 路径解析优先级:vnpy 原生 vt_setting.json(机器相关,正确) >
# yaml data_paths.vnpy_db(模板可能是 NAS Linux 路径,Windows VPS 上不存在)。
# 任一不存在时回退到另一个,避免 VPS 上 yaml 写 NAS 路径导致回测加载 0 数据。
#
# 不能直接用 SETTINGS.get("database.database"):它会被 datareader.read_index_daily
# (基准加载)覆写为 yaml NAS 路径,污染后续调用。这里每次重新读 vt_setting.json
# 原始值(machine-truth),不依赖被污染的 SETTINGS 缓存。
_dcfg = None _dcfg = None
try: try:
from vnpy.trader.setting import SETTINGS from vnpy.trader.setting import SETTINGS
from sanguo_data.config import load_config, find_config_path from sanguo_data.config import load_config, find_config_path
_dcfg = load_config(find_config_path()) _dcfg = load_config(find_config_path())
SETTINGS["database.name"] = "sqlite" SETTINGS["database.name"] = "sqlite"
SETTINGS["database.database"] = _dcfg.data_paths["vnpy_db"] _vt_db = _read_vt_setting_db() # 从 vt_setting.json 直接读(不依赖 SETTINGS dict
_yaml_db = _dcfg.data_paths.get("vnpy_db")
if _vt_db and os.path.exists(_vt_db):
_resolved_db = _vt_db
elif _yaml_db and os.path.exists(_yaml_db):
_resolved_db = _yaml_db
else:
# 都不存在时保留 vnpy SETTINGS(让下游报出真实错误,而不是被 yaml 覆盖)
_resolved_db = _vt_db or _yaml_db
SETTINGS["database.database"] = _resolved_db
# 5m/15m 适配:ashare_engine._load_intraday_data 直查 SQLite 需要 DB 物理路径。
engine.sqlite_db_path = _resolved_db
except Exception as e: except Exception as e:
logging.warning("vnpy 数据库配置加载失败(回测可能无法加载历史数据): %s", e) logging.warning("vnpy 数据库配置加载失败(回测可能无法加载历史数据): %s", e)
+6 -4
View File
@@ -32,7 +32,8 @@ class Orchestrator:
async def submit_cta(self, strategy_class, symbol: str, params: dict, async def submit_cta(self, strategy_class, symbol: str, params: dict,
start: str, end: str, cfg, benchmark: str = "hs300", start: str, end: str, cfg, benchmark: str = "hs300",
capital: float = 1_000_000, position_pct: float = 0.95) -> str: capital: float = 1_000_000, position_pct: float = 0.95,
interval: str = "d") -> str:
"""Submit a CTA backtesting task asynchronously""" """Submit a CTA backtesting task asynchronously"""
# Stable uuid up front → reused as the persisted DB task_id, so runner-id == # Stable uuid up front → reused as the persisted DB task_id, so runner-id ==
# DB task_id (durable across restarts; previously used id(params) memory addr). # DB task_id (durable across restarts; previously used id(params) memory addr).
@@ -48,6 +49,7 @@ class Orchestrator:
benchmark=benchmark, benchmark=benchmark,
capital=capital, capital=capital,
position_pct=position_pct, position_pct=position_pct,
interval=interval,
) )
await self._notify_stage(task_id, "排队中") await self._notify_stage(task_id, "排队中")
@@ -55,7 +57,7 @@ class Orchestrator:
fut: Future = self.pool.submit_work( fut: Future = self.pool.submit_work(
task_id, _cta_worker, spec["strategy_class"], spec["symbol"], task_id, _cta_worker, spec["strategy_class"], spec["symbol"],
spec["params"], spec["start"], spec["end"], spec["cfg"], spec["benchmark"], spec["params"], spec["start"], spec["end"], spec["cfg"], spec["benchmark"],
self.db_path, task_id, spec["capital"], spec["position_pct"] self.db_path, task_id, spec["capital"], spec["position_pct"], spec["interval"]
) )
task = self.pool.get_task(task_id) task = self.pool.get_task(task_id)
@@ -194,10 +196,10 @@ class Orchestrator:
# Module-level worker functions (must be top-level for ProcessPoolExecutor pickle) # Module-level worker functions (must be top-level for ProcessPoolExecutor pickle)
def _cta_worker(strategy_class, symbol: str, params: dict, start: str, end: str, cfg, benchmark: str, db_path: str, task_id: str, capital: float = 1_000_000, position_pct: float = 0.95) -> any: def _cta_worker(strategy_class, symbol: str, params: dict, start: str, end: str, cfg, benchmark: str, db_path: str, task_id: str, capital: float = 1_000_000, position_pct: float = 0.95, interval: str = "d") -> any:
"""Worker for CTA backtest (lazy import, spawn-friendly)""" """Worker for CTA backtest (lazy import, spawn-friendly)"""
from sanguo_backtest.cta_engine import run_cta_backtest from sanguo_backtest.cta_engine import run_cta_backtest
return run_cta_backtest(strategy_class, symbol, params, start, end, cfg, db_path, benchmark=benchmark, task_id=task_id, capital=capital, position_pct=position_pct) return run_cta_backtest(strategy_class, symbol, params, start, end, cfg, db_path, benchmark=benchmark, task_id=task_id, capital=capital, position_pct=position_pct, interval=interval)
def _opt_worker(strategy_class, symbol: str, grid: dict, start: str, end: str, cfg, db_path: str, task_id: str) -> any: def _opt_worker(strategy_class, symbol: str, grid: dict, start: str, end: str, cfg, db_path: str, task_id: str) -> any: