Initial commit: cc-cursor 全链路量化研究平台
7 Sprints 全部完成: Sprint 0: 基础设施 (DataManager + MariaDB) Sprint 1: 因子引擎 (34因子/12分类) Sprint 2: VectorBT 回测 (5策略+截面) Sprint 3: Optuna 优化 (+Walk-Forward) Sprint 4: ML 模型 (LightGBM+CatBoost) Sprint 5: Qwen 情绪因子 (三源新闻+日期对齐) Sprint 6: Agent 系统 (4Agent+日报.md/.html) 生产加固 (15项): Tushare双源fallback, SSH自动恢复, pool_pre_ping, save_daily先删后插, load_dotenv绝对路径, 日报5d/20d修复, RiskAgent改上证指数, 昨日对比+数据截止, mac_report utf8mb4, CLAUDE-*.md 9条已知Bug, demo全参数化, djapi数据源归一化, indexDatas API修正 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
VectorBT 回测引擎封装。
|
||||
|
||||
统一接口:engine.run(strategy, price_df, factor_df) → BacktestReport
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import vectorbt as vbt
|
||||
|
||||
from backtest.base import BaseStrategy
|
||||
from backtest.report import BacktestReport
|
||||
|
||||
|
||||
class VectorBTEngine:
|
||||
"""
|
||||
VectorBT 回测引擎。
|
||||
|
||||
只做多,不做空。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial_capital: float = 100_000,
|
||||
commission: float = 0.0003, # 万三
|
||||
freq: str = "D",
|
||||
):
|
||||
self.initial_capital = initial_capital
|
||||
self.commission = commission
|
||||
self.freq = freq
|
||||
|
||||
# ── 单股票回测 ────────────────────────────────────────
|
||||
|
||||
def run(
|
||||
self,
|
||||
strategy: BaseStrategy,
|
||||
price_df: pd.DataFrame,
|
||||
factor_df: pd.DataFrame | None = None,
|
||||
) -> BacktestReport:
|
||||
"""
|
||||
单股票回测。
|
||||
|
||||
参数:
|
||||
strategy: 策略实例
|
||||
price_df: 价格数据,index=trade_date,必须有 'close' 列
|
||||
factor_df: 因子数据,index=trade_date。
|
||||
None 时使用 price_df 作为因子数据源。
|
||||
|
||||
返回:
|
||||
BacktestReport
|
||||
"""
|
||||
if factor_df is None:
|
||||
factor_df = price_df
|
||||
|
||||
# 1. 对齐日期
|
||||
common_idx = price_df.index.intersection(factor_df.index)
|
||||
if len(common_idx) < 2:
|
||||
return BacktestReport()
|
||||
|
||||
price_df = price_df.loc[common_idx].sort_index()
|
||||
factor_df = factor_df.loc[common_idx].sort_index()
|
||||
|
||||
# 2. 合并 close 到 factor_df(策略可能需要)
|
||||
if "close" not in factor_df.columns:
|
||||
factor_df = factor_df.copy()
|
||||
factor_df["close"] = price_df["close"]
|
||||
|
||||
# 3. 生成信号
|
||||
raw_signals = strategy.generate_signals(factor_df)
|
||||
|
||||
# 4. 信号 → VectorBT entries/exits
|
||||
entries, exits = self._signals_to_entries(raw_signals, price_df.index)
|
||||
|
||||
# 5. 运行回测
|
||||
close = price_df["close"]
|
||||
pf = vbt.Portfolio.from_signals(
|
||||
close,
|
||||
entries=entries,
|
||||
exits=exits,
|
||||
init_cash=self.initial_capital,
|
||||
fees=self.commission,
|
||||
freq=self.freq,
|
||||
direction="longonly",
|
||||
)
|
||||
|
||||
return BacktestReport.from_vbt_result(pf, close)
|
||||
|
||||
# ── 截面回测(多股票) ──────────────────────────────────
|
||||
|
||||
def run_cross_section(
|
||||
self,
|
||||
strategy: BaseStrategy,
|
||||
price_universe: dict[str, pd.DataFrame],
|
||||
factor_universe: dict[str, pd.DataFrame] | None = None,
|
||||
rebalance_freq: str = "M",
|
||||
) -> BacktestReport:
|
||||
"""
|
||||
截面策略回测(多股票 + 定期调仓)。
|
||||
|
||||
对每只股票独立回测,合并权益曲线。
|
||||
|
||||
参数:
|
||||
strategy: 策略实例
|
||||
price_universe: {ts_code: price_df}
|
||||
factor_universe: {ts_code: factor_df}
|
||||
rebalance_freq: 调仓频率 'D'/'W'/'M',用于合并时对齐
|
||||
|
||||
返回:
|
||||
BacktestReport
|
||||
"""
|
||||
if factor_universe is None:
|
||||
factor_universe = price_universe
|
||||
|
||||
stock_equities = {}
|
||||
stock_reports = {}
|
||||
|
||||
# 逐股票回测
|
||||
for ts_code in price_universe:
|
||||
price_df = price_universe[ts_code]
|
||||
if "close" not in price_df.columns or price_df.empty:
|
||||
continue
|
||||
|
||||
factor_df = factor_universe.get(ts_code, price_df)
|
||||
|
||||
report = self.run(strategy, price_df, factor_df)
|
||||
if report is not None and len(report.equity_curve) > 0:
|
||||
stock_equities[ts_code] = report.equity_curve
|
||||
stock_reports[ts_code] = report
|
||||
|
||||
if not stock_equities:
|
||||
return BacktestReport()
|
||||
|
||||
# 合并:等权分配资金到各股票
|
||||
return self._merge_equities(stock_equities)
|
||||
|
||||
# ── 信号转换 ──────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _signals_to_entries(
|
||||
raw_signals: pd.Series,
|
||||
target_index: pd.Index,
|
||||
) -> tuple[pd.Series, pd.Series]:
|
||||
"""
|
||||
将策略信号转为 VectorBT entries/exits。
|
||||
|
||||
信号格式:
|
||||
1 → 买入
|
||||
0 → 平仓
|
||||
-1 → 继续持有/不操作
|
||||
|
||||
entries: True 时开仓
|
||||
exits: True 时平仓
|
||||
"""
|
||||
# 对齐到目标 index
|
||||
aligned = pd.Series(-1, index=target_index)
|
||||
common = target_index.intersection(raw_signals.index)
|
||||
aligned.loc[common] = raw_signals.loc[common].values
|
||||
|
||||
entries = pd.Series(False, index=target_index)
|
||||
exits = pd.Series(False, index=target_index)
|
||||
|
||||
in_position = False
|
||||
for i in range(len(aligned)):
|
||||
sig = aligned.iloc[i]
|
||||
if not in_position and sig == 1:
|
||||
entries.iloc[i] = True
|
||||
in_position = True
|
||||
elif in_position and sig == 0:
|
||||
exits.iloc[i] = True
|
||||
in_position = False
|
||||
|
||||
return entries, exits
|
||||
|
||||
# ── 合并多股票权益 ─────────────────────────────────────
|
||||
|
||||
def _merge_equities(
|
||||
self, stock_equities: dict[str, pd.Series]
|
||||
) -> BacktestReport:
|
||||
"""等权合并多股票权益曲线,构建组合级报告。"""
|
||||
equity_df = pd.DataFrame(stock_equities)
|
||||
equity_df = equity_df.ffill().fillna(0)
|
||||
# 转为 DatetimeIndex
|
||||
if not isinstance(equity_df.index, pd.DatetimeIndex):
|
||||
equity_df.index = pd.to_datetime(equity_df.index, format="%Y%m%d")
|
||||
|
||||
n_stocks = len(stock_equities)
|
||||
weight = 1.0 / n_stocks if n_stocks > 0 else 1.0
|
||||
|
||||
# 加权组合收益
|
||||
returns_df = equity_df.pct_change().fillna(0)
|
||||
portfolio_ret = returns_df.mean(axis=1) # 等权 = 逐行平均
|
||||
|
||||
# 组合净值
|
||||
portfolio_equity = self.initial_capital * (1 + portfolio_ret).cumprod()
|
||||
|
||||
dd = portfolio_equity / portfolio_equity.cummax() - 1
|
||||
years = max(len(portfolio_ret) / 252, 0.02)
|
||||
|
||||
total_return = (portfolio_equity.iloc[-1] / portfolio_equity.iloc[0] - 1) * 100
|
||||
cagr = ((total_return / 100 + 1) ** (1 / years) - 1) * 100
|
||||
mdd = dd.min() * 100
|
||||
mean_ret = portfolio_ret.mean() * 252
|
||||
std_ret = portfolio_ret.std() * np.sqrt(252)
|
||||
sharpe = mean_ret / std_ret if std_ret > 0 else 0
|
||||
calmar = cagr / abs(mdd) if abs(mdd) > 0 else 0
|
||||
|
||||
try:
|
||||
monthly = portfolio_equity.resample("ME").last().pct_change()
|
||||
except Exception:
|
||||
monthly = pd.Series(dtype=float)
|
||||
|
||||
return BacktestReport(
|
||||
total_return=round(total_return, 2),
|
||||
cagr=round(cagr, 2),
|
||||
max_drawdown=round(mdd, 2),
|
||||
sharpe_ratio=round(sharpe, 2),
|
||||
calmar_ratio=round(calmar, 2),
|
||||
annual_volatility=round(std_ret * 100 if std_ret != 0 else 0, 2),
|
||||
total_trades=0,
|
||||
equity_curve=portfolio_equity,
|
||||
drawdown_curve=dd,
|
||||
monthly_returns=monthly,
|
||||
)
|
||||
Reference in New Issue
Block a user