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,53 @@
|
||||
"""
|
||||
策略抽象基类。
|
||||
|
||||
所有策略必须继承 BaseStrategy,实现 generate_signals(factor_df) → pd.Series。
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class BaseStrategy(ABC):
|
||||
"""
|
||||
回测策略基类。
|
||||
|
||||
属性:
|
||||
name: 策略名称
|
||||
category: 'trend' | 'mean_revert' | 'rotation'
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
category: str = ""
|
||||
|
||||
@abstractmethod
|
||||
def generate_signals(self, factor_df: pd.DataFrame) -> pd.Series:
|
||||
"""
|
||||
因子 → 交易信号。
|
||||
|
||||
参数:
|
||||
factor_df: 因子 DataFrame,index=trade_date,columns=因子名
|
||||
|
||||
返回:
|
||||
pd.Series,index 与 factor_df 对齐:
|
||||
1=买入, 0=平仓/无操作
|
||||
(只做多,不做空)
|
||||
"""
|
||||
...
|
||||
|
||||
def get_params(self) -> dict:
|
||||
"""返回策略当前参数(供 Optuna 优化用)。"""
|
||||
return {
|
||||
k: v for k, v in self.__dict__.items()
|
||||
if not k.startswith("_") and k not in ("name", "category")
|
||||
}
|
||||
|
||||
def set_params(self, **kwargs) -> None:
|
||||
"""设置策略参数。"""
|
||||
for k, v in kwargs.items():
|
||||
if hasattr(self, k):
|
||||
setattr(self, k, v)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(name='{self.name}')"
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
标准化回测报告。
|
||||
|
||||
与回测引擎解耦,后续换引擎只需改构造函数。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestReport:
|
||||
"""回测报告"""
|
||||
|
||||
# 核心收益指标
|
||||
total_return: float = 0.0
|
||||
cagr: float = 0.0
|
||||
max_drawdown: float = 0.0
|
||||
sharpe_ratio: float = 0.0
|
||||
calmar_ratio: float = 0.0
|
||||
annual_volatility: float = 0.0
|
||||
|
||||
# 交易统计
|
||||
win_rate: float = 0.0
|
||||
profit_factor: float = 0.0
|
||||
total_trades: int = 0
|
||||
avg_hold_days: float = 0.0
|
||||
best_trade_pct: float = 0.0
|
||||
worst_trade_pct: float = 0.0
|
||||
|
||||
# 序列数据
|
||||
equity_curve: pd.Series = field(default_factory=pd.Series)
|
||||
drawdown_curve: pd.Series = field(default_factory=pd.Series)
|
||||
monthly_returns: pd.Series = field(default_factory=pd.Series)
|
||||
trades_df: pd.DataFrame = field(default_factory=pd.DataFrame)
|
||||
|
||||
# 原始 stats
|
||||
stats_dict: dict = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_vbt_result(cls, pf, close: pd.Series) -> "BacktestReport":
|
||||
"""从 VectorBT Portfolio 结果构建报告。"""
|
||||
stats = pf.stats()
|
||||
|
||||
def _pct(v):
|
||||
"""VectorBT stats 值已为百分比 float,直接返回。"""
|
||||
if v is None:
|
||||
return 0.0
|
||||
try:
|
||||
return float(v)
|
||||
except (ValueError, TypeError):
|
||||
return 0.0
|
||||
|
||||
def _duration_days(v):
|
||||
"""Timedelta → 天数。"""
|
||||
if v is None:
|
||||
return 0.0
|
||||
try:
|
||||
return v.total_seconds() / 86400
|
||||
except AttributeError:
|
||||
return float(v) if v is not None else 0.0
|
||||
|
||||
equity = pf.value()
|
||||
equity = pd.Series(equity.values, index=close.index[: len(equity)])
|
||||
|
||||
if not isinstance(equity.index, pd.DatetimeIndex):
|
||||
equity.index = pd.to_datetime(equity.index, format="%Y%m%d")
|
||||
|
||||
dd = equity / equity.cummax() - 1
|
||||
daily_ret = equity.pct_change().dropna()
|
||||
|
||||
years = len(daily_ret) / 252 if len(daily_ret) > 0 else 1
|
||||
total_ret = (equity.iloc[-1] / equity.iloc[0] - 1) * 100 if len(equity) > 1 else 0
|
||||
cagr = ((total_ret / 100 + 1) ** (1 / years) - 1) * 100 if years > 0 else 0
|
||||
mdd = dd.min() * 100
|
||||
ann_vol = daily_ret.std() * np.sqrt(252) * 100 if len(daily_ret) > 0 else 0
|
||||
|
||||
mean_ret = daily_ret.mean() * 252
|
||||
std_ret = daily_ret.std() * np.sqrt(252)
|
||||
sharpe = mean_ret / std_ret if std_ret > 0 else 0
|
||||
calmar = cagr / abs(mdd) if mdd != 0 else 0
|
||||
|
||||
trades = pf.trades.records_readable if hasattr(pf, "trades") else pd.DataFrame()
|
||||
|
||||
try:
|
||||
monthly = equity.resample("ME").last().pct_change()
|
||||
except Exception:
|
||||
monthly = pd.Series(dtype=float)
|
||||
|
||||
pf_factor = stats.get("Profit Factor", 0)
|
||||
if pf_factor is None or np.isinf(float(pf_factor)):
|
||||
pf_factor = 0.0
|
||||
else:
|
||||
pf_factor = float(pf_factor)
|
||||
|
||||
return cls(
|
||||
total_return=round(total_ret, 2),
|
||||
cagr=round(cagr, 2),
|
||||
max_drawdown=round(mdd, 2),
|
||||
sharpe_ratio=round(sharpe, 2),
|
||||
calmar_ratio=round(calmar, 2),
|
||||
annual_volatility=round(ann_vol, 2),
|
||||
win_rate=_pct(stats.get("Win Rate [%]", 0)),
|
||||
profit_factor=pf_factor,
|
||||
total_trades=int(stats.get("Total Trades", 0)),
|
||||
avg_hold_days=_duration_days(stats.get("Avg Winning Trade Duration", None)),
|
||||
best_trade_pct=_pct(stats.get("Best Trade [%]", 0)),
|
||||
worst_trade_pct=_pct(stats.get("Worst Trade [%]", 0)),
|
||||
equity_curve=equity,
|
||||
drawdown_curve=dd,
|
||||
monthly_returns=monthly,
|
||||
trades_df=trades,
|
||||
stats_dict={k: str(v) for k, v in stats.items()},
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""核心指标转字典。"""
|
||||
return {
|
||||
"total_return": self.total_return,
|
||||
"cagr": self.cagr,
|
||||
"max_drawdown": self.max_drawdown,
|
||||
"sharpe_ratio": self.sharpe_ratio,
|
||||
"calmar_ratio": self.calmar_ratio,
|
||||
"annual_volatility": self.annual_volatility,
|
||||
"win_rate": self.win_rate,
|
||||
"profit_factor": self.profit_factor,
|
||||
"total_trades": self.total_trades,
|
||||
}
|
||||
|
||||
def summary(self) -> str:
|
||||
"""一行摘要。"""
|
||||
return (
|
||||
f"收益={self.total_return:.1f}% "
|
||||
f"年化={self.cagr:.1f}% "
|
||||
f"回撤={self.max_drawdown:.1f}% "
|
||||
f"夏普={self.sharpe_ratio:.2f} "
|
||||
f"胜率={self.win_rate:.1f}% "
|
||||
f"交易={self.total_trades}笔"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.summary()
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
信号生成工具函数。
|
||||
|
||||
因子值 → 交易信号的桥梁,纯函数无副作用。
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def factor_to_threshold_signal(
|
||||
factor_series: pd.Series,
|
||||
buy_threshold: float,
|
||||
sell_threshold: float | None = None,
|
||||
cross_direction: str = "up",
|
||||
) -> pd.Series:
|
||||
"""
|
||||
因子阈值交叉信号。
|
||||
|
||||
参数:
|
||||
factor_series: 因子值 Series
|
||||
buy_threshold: 买入阈值(如 RSI < 30 则买)
|
||||
sell_threshold: 卖出阈值(如 RSI > 70 则卖),None 表示平所有仓
|
||||
cross_direction: 'up'=因子向上穿越阈值时触发, 'down'=向下穿越
|
||||
|
||||
返回:
|
||||
信号 Series:1=买入, 0=平仓
|
||||
"""
|
||||
signals = pd.Series(0, index=factor_series.index)
|
||||
|
||||
if cross_direction == "down":
|
||||
buys = factor_series < buy_threshold
|
||||
else:
|
||||
buys = factor_series > buy_threshold
|
||||
|
||||
signals[buys] = 1
|
||||
|
||||
if sell_threshold is not None:
|
||||
if cross_direction == "down":
|
||||
sells = factor_series > sell_threshold
|
||||
else:
|
||||
sells = factor_series < sell_threshold
|
||||
signals[sells] = 0
|
||||
|
||||
# 过滤连续信号
|
||||
signals = _filter_consecutive(signals)
|
||||
|
||||
return signals
|
||||
|
||||
|
||||
def factor_to_quantile_signal(
|
||||
factor_series: pd.Series,
|
||||
top_quantile: float = 0.8,
|
||||
bottom_quantile: float = 0.2,
|
||||
) -> pd.Series:
|
||||
"""
|
||||
因子分位数信号 — 按滚动分位数判断。
|
||||
|
||||
参数:
|
||||
factor_series: 因子值
|
||||
top_quantile: 高于此分位买入
|
||||
bottom_quantile: 低于此分位平仓
|
||||
|
||||
返回:
|
||||
信号 Series
|
||||
"""
|
||||
top = factor_series.quantile(top_quantile)
|
||||
bottom = factor_series.quantile(bottom_quantile)
|
||||
|
||||
signals = pd.Series(0, index=factor_series.index)
|
||||
signals[factor_series > top] = 1
|
||||
signals[factor_series < bottom] = 0
|
||||
|
||||
return _filter_consecutive(signals)
|
||||
|
||||
|
||||
def cross_signal(
|
||||
fast: pd.Series,
|
||||
slow: pd.Series,
|
||||
) -> pd.Series:
|
||||
"""
|
||||
金叉/死叉信号。
|
||||
|
||||
fast 上穿 slow → 买入(1)
|
||||
fast 下穿 slow → 平仓(0)
|
||||
"""
|
||||
fast = fast.dropna()
|
||||
slow = slow.dropna()
|
||||
common_idx = fast.index.intersection(slow.index)
|
||||
fast, slow = fast[common_idx], slow[common_idx]
|
||||
|
||||
signals = pd.Series(-1, index=common_idx)
|
||||
above = (fast > slow).fillna(False)
|
||||
above = above.infer_objects(copy=False)
|
||||
# 交叉点:今天 above=True 且昨天 above=False → 金叉
|
||||
prev = above.shift(1).fillna(False)
|
||||
prev = prev.infer_objects(copy=False)
|
||||
cross_up = above & ~prev
|
||||
cross_down = ~above & prev
|
||||
|
||||
signals[cross_up] = 1
|
||||
signals[cross_down] = 0
|
||||
|
||||
return _filter_consecutive(signals)
|
||||
|
||||
|
||||
def _filter_consecutive(signals: pd.Series) -> pd.Series:
|
||||
"""过滤连续相同信号,只保留首次出现的信号。"""
|
||||
result = signals.copy()
|
||||
prev = None
|
||||
for i in range(len(result)):
|
||||
if result.iloc[i] == prev:
|
||||
result.iloc[i] = -1 # 标记为不操作
|
||||
else:
|
||||
prev = result.iloc[i]
|
||||
return result[result != -1].reindex(signals.index).fillna(-1)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
因子阈值交叉策略。
|
||||
|
||||
通用策略:任意因子上穿/下穿阈值 → 交易信号。
|
||||
|
||||
支持:
|
||||
- 上穿买入 (cross_up: close < MA → cross above MA → buy)
|
||||
- 下穿买入 (cross_down: RSI > 70 → cross below 30 → buy)
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.base import BaseStrategy
|
||||
from backtest.signal import factor_to_threshold_signal
|
||||
|
||||
|
||||
class FactorCrossStrategy(BaseStrategy):
|
||||
"""
|
||||
因子阈值交叉策略。
|
||||
|
||||
适用场景:
|
||||
- 均线偏离度上穿 0 → 买入(趋势转多)
|
||||
- 波动率下穿阈值 → 买入(波动收敛后突破)
|
||||
"""
|
||||
|
||||
category = "trend"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
factor_column: str,
|
||||
buy_threshold: float, # 因子大于此值买
|
||||
sell_threshold: float | None = None,
|
||||
cross_direction: str = "up",
|
||||
):
|
||||
self.factor_column = factor_column
|
||||
self.buy_threshold = buy_threshold
|
||||
self.sell_threshold = sell_threshold
|
||||
self.cross_direction = cross_direction
|
||||
self.name = f"factor_cross_{factor_column}"
|
||||
|
||||
def generate_signals(self, factor_df: pd.DataFrame) -> pd.Series:
|
||||
if self.factor_column not in factor_df.columns:
|
||||
raise ValueError(f"factor_df 缺少 '{self.factor_column}' 列")
|
||||
|
||||
factor = factor_df[self.factor_column]
|
||||
return factor_to_threshold_signal(
|
||||
factor,
|
||||
buy_threshold=self.buy_threshold,
|
||||
sell_threshold=self.sell_threshold,
|
||||
cross_direction=self.cross_direction,
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""
|
||||
因子排序轮动策略。
|
||||
|
||||
定期按因子值排序,买入排名最高的股票(截面策略)。
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.base import BaseStrategy
|
||||
|
||||
|
||||
class FactorRotationStrategy(BaseStrategy):
|
||||
"""
|
||||
因子排序选股策略。
|
||||
|
||||
适用于多股票截面场景:对每只股票计算因子值,
|
||||
选排名最高的 top_n 只做多。
|
||||
"""
|
||||
|
||||
category = "rotation"
|
||||
|
||||
def __init__(self, factor_name: str, top_n: int = 5, bottom_n: int = 0):
|
||||
self.factor_name = factor_name
|
||||
self.top_n = top_n
|
||||
self.bottom_n = bottom_n
|
||||
self.name = f"rotation_{factor_name}_top{top_n}"
|
||||
|
||||
def generate_signals(self, factor_df: pd.DataFrame) -> pd.Series:
|
||||
"""
|
||||
单股票/截面模式:factor_df 支持两种输入方式。
|
||||
- 单股票: 对每只股票单次调用
|
||||
- 截面: 通过 run_cross_section 逐股票调用
|
||||
"""
|
||||
if self.factor_name not in factor_df.columns:
|
||||
raise ValueError(f"factor_df 缺少 '{self.factor_name}' 列")
|
||||
|
||||
factor = factor_df[self.factor_name]
|
||||
valid = factor.dropna()
|
||||
if len(valid) < self.top_n * 2:
|
||||
return pd.Series(-1, index=factor_df.index)
|
||||
|
||||
threshold = valid.quantile(1 - self.top_n / max(len(valid), self.top_n))
|
||||
signals = pd.Series(-1, index=factor_df.index)
|
||||
signals[factor > threshold] = 1
|
||||
|
||||
return signals
|
||||
|
||||
def rank_stocks(
|
||||
self, factor_values: dict[str, float]
|
||||
) -> list[str]:
|
||||
"""
|
||||
对股票按因子值排序,返回 top N 的 ts_code 列表。
|
||||
|
||||
参数:
|
||||
factor_values: {ts_code: factor_value}
|
||||
"""
|
||||
sorted_stocks = sorted(factor_values, key=factor_values.get, reverse=True)
|
||||
return sorted_stocks[: self.top_n]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
动量突破策略。
|
||||
|
||||
价格突破 N 日新高 → 买入
|
||||
价格跌破 N 日均线 → 平仓
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.base import BaseStrategy
|
||||
from backtest.signal import factor_to_threshold_signal
|
||||
|
||||
|
||||
class MomentumBreakoutStrategy(BaseStrategy):
|
||||
"""动量突破策略。"""
|
||||
|
||||
category = "trend"
|
||||
|
||||
def __init__(self, lookback: int = 20, exit_period: int = 10):
|
||||
self.lookback = lookback
|
||||
self.exit_period = exit_period
|
||||
self.name = f"mom_breakout_{lookback}"
|
||||
|
||||
def generate_signals(self, factor_df: pd.DataFrame) -> pd.Series:
|
||||
if "close" not in factor_df.columns:
|
||||
raise ValueError("factor_df 缺少 'close' 列")
|
||||
|
||||
close = factor_df["close"]
|
||||
# 买入信号:突破 N 日新高
|
||||
rolling_high = close.rolling(window=self.lookback, min_periods=self.lookback).max()
|
||||
breakout = close >= rolling_high.shift(1)
|
||||
|
||||
# 平仓信号:跌破 exit 日均线
|
||||
exit_ma = close.rolling(window=self.exit_period, min_periods=self.exit_period).mean()
|
||||
|
||||
signals = pd.Series(0, index=close.index)
|
||||
signals[breakout] = 1
|
||||
signals[close < exit_ma] = 0
|
||||
|
||||
return self._dedup(signals)
|
||||
|
||||
@staticmethod
|
||||
def _dedup(signals: pd.Series) -> pd.Series:
|
||||
"""只保留第一个买入和第一个卖出信号。"""
|
||||
result = signals.copy()
|
||||
prev = -1
|
||||
for i in range(len(result)):
|
||||
if result.iloc[i] == prev:
|
||||
result.iloc[i] = -1
|
||||
else:
|
||||
prev = result.iloc[i]
|
||||
return result[result != -1].reindex(signals.index).fillna(-1)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
RSI 均值回归策略。
|
||||
|
||||
RSI 低于超卖线 → 买入
|
||||
RSI 高于超买线 → 平仓
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.base import BaseStrategy
|
||||
from backtest.signal import factor_to_threshold_signal
|
||||
|
||||
|
||||
class RSIMeanRevertStrategy(BaseStrategy):
|
||||
"""RSI 超买超卖反转策略。"""
|
||||
|
||||
category = "mean_revert"
|
||||
|
||||
def __init__(self, oversold: float = 30, overbought: float = 70, rsi_column: str = "rsi_14"):
|
||||
self.oversold = oversold
|
||||
self.overbought = overbought
|
||||
self.rsi_column = rsi_column
|
||||
self.name = f"rsi_revert_{int(oversold)}_{int(overbought)}"
|
||||
|
||||
def generate_signals(self, factor_df: pd.DataFrame) -> pd.Series:
|
||||
if self.rsi_column not in factor_df.columns:
|
||||
raise ValueError(f"factor_df 缺少 '{self.rsi_column}' 列")
|
||||
|
||||
rsi = factor_df[self.rsi_column]
|
||||
return factor_to_threshold_signal(
|
||||
rsi,
|
||||
buy_threshold=self.oversold,
|
||||
sell_threshold=self.overbought,
|
||||
cross_direction="down", # RSI 向下跌破 oversold → 买入
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
均线交叉策略。
|
||||
|
||||
短期均线上穿长期均线 → 买入
|
||||
短期均线下穿长期均线 → 平仓
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from backtest.base import BaseStrategy
|
||||
from backtest.signal import cross_signal
|
||||
|
||||
|
||||
class SMACrossStrategy(BaseStrategy):
|
||||
"""快慢均线交叉策略。"""
|
||||
|
||||
category = "trend"
|
||||
|
||||
def __init__(self, fast: int = 5, slow: int = 20):
|
||||
self.fast = fast
|
||||
self.slow = slow
|
||||
self.name = f"sma_cross_{fast}_{slow}"
|
||||
|
||||
def generate_signals(self, factor_df: pd.DataFrame) -> pd.Series:
|
||||
if "close" not in factor_df.columns:
|
||||
raise ValueError("factor_df 缺少 'close' 列")
|
||||
|
||||
close = factor_df["close"]
|
||||
min_p = min(self.fast, self.slow)
|
||||
ma_fast = close.rolling(window=self.fast, min_periods=self.fast).mean()
|
||||
ma_slow = close.rolling(window=self.slow, min_periods=self.slow).mean()
|
||||
|
||||
return cross_signal(ma_fast, ma_slow)
|
||||
@@ -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