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:
2026-06-07 15:59:05 +08:00
co-authored by Claude Opus 4.7
commit 271a9343a5
293 changed files with 59598 additions and 0 deletions
View File
+53
View File
@@ -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: 因子 DataFrameindex=trade_datecolumns=因子名
返回:
pd.Seriesindex 与 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}')"
+144
View File
@@ -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()
+115
View File
@@ -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'=向下穿越
返回:
信号 Series1=买入, 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 → 买入
)
+33
View File
@@ -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)
+223
View File
@@ -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,
)