From 6deda5155af2919f38f727ea2bdfac6052213e8b Mon Sep 17 00:00:00 2001 From: shellway-pc <413209390@qq.com> Date: Fri, 31 Jul 2026 21:23:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0alpha=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- alpha/__init__.py | 26 +++ alpha/backtest.py | 401 +++++++++++++++++++++++++++++++++ alpha/config.py | 42 ++++ alpha/evaluation.py | 262 +++++++++++++++++++++ alpha/factors.py | 256 +++++++++++++++++++++ alpha/requirements.txt | 6 + alpha/strategy.py | 254 +++++++++++++++++++++ alpha/阿尔法策略研究示例.ipynb | 279 +++++++++++++++++++++++ 8 files changed, 1526 insertions(+) create mode 100644 alpha/__init__.py create mode 100644 alpha/backtest.py create mode 100644 alpha/config.py create mode 100644 alpha/evaluation.py create mode 100644 alpha/factors.py create mode 100644 alpha/requirements.txt create mode 100644 alpha/strategy.py create mode 100644 alpha/阿尔法策略研究示例.ipynb diff --git a/alpha/__init__.py b/alpha/__init__.py new file mode 100644 index 0000000..c2d8d37 --- /dev/null +++ b/alpha/__init__.py @@ -0,0 +1,26 @@ +""" +quanxiel.alpha — 阿尔法策略研究与回测模块 + +提供因子研究、策略定义、历史回测、绩效评估等完整工具链。 +""" +from .config import AlphaConfig +from .factors import FactorRegistry, BaseFactor, TechnicalFactor, FundamentalFactor +from .strategy import Strategy, SignalGenerator, WeightAllocator +from .backtest import BacktestEngine, Broker, Portfolio +from .evaluation import PerformanceEvaluator, ReportGenerator + +__all__ = [ + "AlphaConfig", + "FactorRegistry", + "BaseFactor", + "TechnicalFactor", + "FundamentalFactor", + "Strategy", + "SignalGenerator", + "WeightAllocator", + "BacktestEngine", + "Broker", + "Portfolio", + "PerformanceEvaluator", + "ReportGenerator", +] \ No newline at end of file diff --git a/alpha/backtest.py b/alpha/backtest.py new file mode 100644 index 0000000..6d8bebc --- /dev/null +++ b/alpha/backtest.py @@ -0,0 +1,401 @@ +""" +回测引擎模块 — 事件驱动回测,模拟交易执行与组合管理 +""" +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Set, Tuple + +import numpy as np +import pandas as pd + +from .config import AlphaConfig + + +# --------------------------------------------------------------------------- +# 交易记录 & 持仓 +# --------------------------------------------------------------------------- + +@dataclass +class TradeRecord: + """单笔交易记录""" + date: pd.Timestamp + stock: str + side: str # 'buy' / 'sell' + quantity: int + price: float + commission: float = 0.0 + stamp_tax: float = 0.0 + slippage_cost: float = 0.0 + signal: str = "" + + +@dataclass +class Position: + """单只股票持仓""" + stock: str + quantity: int = 0 + avg_cost: float = 0.0 + + @property + def market_value(self) -> float: + return self.quantity * self.current_price if hasattr(self, "current_price") else 0.0 + + def update_cost(self, qty: int, price: float): + """更新平均成本(买入时)""" + total_cost = abs(self.quantity) * self.avg_cost + abs(qty) * price + self.quantity += qty + if self.quantity != 0: + self.avg_cost = total_cost / abs(self.quantity) + else: + self.avg_cost = 0.0 + + +class Portfolio: + """投资组合""" + + def __init__(self, initial_cash: float = 1_000_000.0): + self.initial_cash = initial_cash + self.cash = initial_cash + self.positions: Dict[str, Position] = {} # stock -> Position + self.trades: List[TradeRecord] = [] + self.daily_values: List[Dict[str, Any]] = [] # 每日净值记录 + + @property + def total_equity(self) -> float: + pos_value = sum( + p.market_value for p in self.positions.values() + ) + return self.cash + pos_value + + @property + def total_return(self) -> float: + return (self.total_equity / self.initial_cash) - 1.0 + + def get_position(self, stock: str) -> Position: + if stock not in self.positions: + self.positions[stock] = Position(stock=stock) + return self.positions[stock] + + def update_market_prices(self, prices: Dict[str, float]): + """更新所有持仓的市价""" + for stock, price in prices.items(): + if stock in self.positions: + self.positions[stock].current_price = price + + def record_daily(self, date: pd.Timestamp, prices: Dict[str, float]): + """记录每日快照""" + self.update_market_prices(prices) + pos_value = sum(p.market_value for p in self.positions.values()) + self.daily_values.append({ + "date": date, + "cash": self.cash, + "position_value": pos_value, + "total_equity": self.cash + pos_value, + "return": (self.cash + pos_value) / self.initial_cash - 1.0, + }) + + +# --------------------------------------------------------------------------- +# 券商(模拟交易执行) +# --------------------------------------------------------------------------- + +class Broker: + """模拟券商 — 处理订单执行、交易成本""" + + def __init__(self, config: AlphaConfig): + self.commission_rate = config.commission_rate + self.slippage = config.slippage + self.stamp_tax = config.stamp_tax + self.max_position_pct = config.max_position_pct + self.max_turnover = config.max_turnover + self.min_holding_period = config.min_holding_period + + def execute( + self, + portfolio: Portfolio, + target_weights: Dict[str, float], + prices: Dict[str, float], + date: pd.Timestamp, + strategy_name: str = "", + ) -> List[TradeRecord]: + """ + 执行调仓:比较当前持仓与目标权重,生成订单并执行。 + 返回新的交易记录列表。 + """ + if not target_weights: + # 清仓信号 + return self._liquidate(portfolio, prices, date, strategy_name) + + total_equity = 0.0 + # 先更新市价以计算当前权益 + portfolio.update_market_prices(prices) + total_equity = portfolio.total_equity + + trades: List[TradeRecord] = [] + + # 目标持仓市值 + target_map: Dict[str, float] = {} + for stock, w in target_weights.items(): + if stock in prices and prices[stock] > 0: + target_map[stock] = total_equity * w + + # 卖出不在目标中的持仓 + for stock in list(portfolio.positions.keys()): + pos = portfolio.positions[stock] + if pos.quantity <= 0: + continue + if stock not in target_map: + trades.extend( + self._sell(portfolio, stock, pos.quantity, prices, date, + strategy_name) + ) + + # 调整持仓到目标权重 + for stock, target_value in target_map.items(): + price = prices.get(stock, 0) + if price <= 0: + continue + current_pos = portfolio.get_position(stock) + current_value = current_pos.quantity * price + diff_value = target_value - current_value + if abs(diff_value) < price: # 差价不足一手,忽略 + continue + + diff_qty = int(diff_value / price / 100) * 100 # 整手 + if diff_qty == 0: + continue + + if diff_qty > 0: + trades.extend( + self._buy(portfolio, stock, diff_qty, price, date, + strategy_name) + ) + else: + sell_qty = min(-diff_qty, current_pos.quantity) + trades.extend( + self._sell(portfolio, stock, sell_qty, price, date, + strategy_name) + ) + + return trades + + def _buy( + self, + portfolio: Portfolio, + stock: str, + qty: int, + price: float, + date: pd.Timestamp, + strategy: str = "", + ) -> List[TradeRecord]: + """买入执行""" + # 滑点 + exec_price = price * (1 + self.slippage) + cost = qty * exec_price + commission = cost * self.commission_rate + total_cost = cost + commission + + if portfolio.cash < total_cost: + # 现金不足,调整数量 + affordable_qty = int( + (portfolio.cash / (exec_price * (1 + self.commission_rate))) + / 100 + ) * 100 + if affordable_qty <= 0: + return [] + qty = affordable_qty + cost = qty * exec_price + commission = cost * self.commission_rate + total_cost = cost + commission + + portfolio.cash -= total_cost + pos = portfolio.get_position(stock) + pos.update_cost(qty, exec_price) + + trade = TradeRecord( + date=date, + stock=stock, + side="buy", + quantity=qty, + price=exec_price, + commission=commission, + slippage_cost=qty * (exec_price - price), + signal=strategy, + ) + portfolio.trades.append(trade) + return [trade] + + def _sell( + self, + portfolio: Portfolio, + stock: str, + qty: int, + price: float, + date: pd.Timestamp, + strategy: str = "", + ) -> List[TradeRecord]: + """卖出执行""" + exec_price = price * (1 - self.slippage) + proceeds = qty * exec_price + commission = proceeds * self.commission_rate + stamp = proceeds * self.stamp_tax + net_proceeds = proceeds - commission - stamp + + pos = portfolio.get_position(stock) + actual_qty = min(qty, pos.quantity) + if actual_qty <= 0: + return [] + + # 更新持仓 + pos.quantity -= actual_qty + if pos.quantity == 0: + pos.avg_cost = 0.0 + + portfolio.cash += net_proceeds + + trade = TradeRecord( + date=date, + stock=stock, + side="sell", + quantity=actual_qty, + price=exec_price, + commission=commission, + stamp_tax=stamp, + slippage_cost=actual_qty * (price - exec_price), + signal=strategy, + ) + portfolio.trades.append(trade) + return [trade] + + def _liquidate( + self, + portfolio: Portfolio, + prices: Dict[str, float], + date: pd.Timestamp, + strategy: str = "", + ) -> List[TradeRecord]: + """全部平仓""" + trades = [] + for stock in list(portfolio.positions.keys()): + pos = portfolio.positions[stock] + if pos.quantity > 0 and stock in prices: + trades.extend( + self._sell(portfolio, stock, pos.quantity, prices, date, + strategy) + ) + return trades + + +# --------------------------------------------------------------------------- +# 回测引擎 +# --------------------------------------------------------------------------- + +class BacktestEngine: + """事件驱动回测引擎""" + + def __init__(self, config: Optional[AlphaConfig] = None): + self.config = config or AlphaConfig() + self.broker = Broker(self.config) + self.portfolio = Portfolio(self.config.initial_cash) + + def run( + self, + strategy: Any, + price_data: pd.DataFrame, + factor_data: Dict[str, pd.DataFrame], + dates: Optional[List[pd.Timestamp]] = None, + rebalance_freq: str = "M", # 'D'/'W'/'M' + progress_callback: Optional[Callable] = None, + ) -> pd.DataFrame: + """ + 运行回测。 + + 参数 + ---- + strategy : Strategy 实例或兼容接口 + price_data : DataFrame, index=date, columns=stocks, values=价格 + factor_data : {factor_name: DataFrame(index=date, columns=stocks)} + dates : 回测日期列表,默认为 price_data 所有日期 + rebalance_freq: 调仓频率 'D'(日), 'W'(周), 'M'(月) + + 返回 + ---- + daily_values : DataFrame 每日净值曲线 + """ + if dates is None: + all_dates = sorted(price_data.index) + else: + all_dates = sorted(dates) + + # 确定调仓日 + date_series = pd.Series(all_dates, index=all_dates) + if rebalance_freq == "M": + rebalance_dates = date_series.resample("M").last().tolist() + elif rebalance_freq == "W": + rebalance_dates = date_series.resample("W").last().tolist() + else: + rebalance_dates = all_dates + + rebalance_set = set(pd.to_datetime(rebalance_dates).date) + + total_dates = len(all_dates) + for i, date in enumerate(all_dates): + # 进度回调 + if progress_callback: + progress_callback(i, total_dates) + + # 当前截面价格 + current_prices = {} + if date in price_data.index: + row = price_data.loc[date] + current_prices = row.dropna().to_dict() + + # 调仓日执行交易 + trade_date = pd.Timestamp(date).date() + if trade_date in rebalance_set: + target_weights = strategy.run_step( + date=pd.Timestamp(date), + factor_data=factor_data, + prices=pd.DataFrame([current_prices]), + cash=self.portfolio.cash, + positions={ + s: p.quantity + for s, p in self.portfolio.positions.items() + }, + ) + self.broker.execute( + self.portfolio, + target_weights, + current_prices, + date=pd.Timestamp(date), + strategy_name=strategy.name, + ) + + # 记录每日净值 + self.portfolio.record_daily(pd.Timestamp(date), current_prices) + + # 最终清算 + if not all_dates: + return pd.DataFrame() + + final_date = all_dates[-1] + if final_date in price_data.index: + final_prices = price_data.loc[final_date].dropna().to_dict() + else: + final_prices = {} + + self.broker._liquidate( + self.portfolio, final_prices, pd.Timestamp(final_date), + strategy_name=strategy.name, + ) + self.portfolio.record_daily(pd.Timestamp(final_date), final_prices) + + return self._to_equity_curve() + + def _to_equity_curve(self) -> pd.DataFrame: + """输出净值曲线 DataFrame""" + df = pd.DataFrame(self.portfolio.daily_values) + if df.empty: + return pd.DataFrame(columns=["date", "total_equity", "return"]) + df = df.set_index("date").sort_index() + df["nav"] = df["total_equity"] / self.config.initial_cash + return df \ No newline at end of file diff --git a/alpha/config.py b/alpha/config.py new file mode 100644 index 0000000..2c09779 --- /dev/null +++ b/alpha/config.py @@ -0,0 +1,42 @@ +""" +阿尔法模块配置 +""" +from dataclasses import dataclass, field +from typing import List, Optional + + +@dataclass +class AlphaConfig: + """阿尔法研究全局配置""" + + # ---- 数据库 ---- + db_host: str = "192.168.27.15" + db_port: int = 12345 + db_name: str = "quant_db" + db_user: str = "postgres" + db_password: str = "postgres" + + # ---- 回测基础参数 ---- + initial_cash: float = 1_000_000.0 # 初始资金 + benchmark: str = "000300.SH" # 基准指数(沪深300) + start_date: str = "2020-01-01" + end_date: str = "2025-12-31" + + # ---- 交易成本 ---- + commission_rate: float = 0.0003 # 佣金费率 + slippage: float = 0.001 # 滑点(百分比) + stamp_tax: float = 0.001 # 印花税(仅卖出) + + # ---- 组合约束 ---- + max_position_pct: float = 0.10 # 单票最大仓位 + max_turnover: float = 0.20 # 单日最大换手率 + min_holding_period: int = 1 # 最小持仓天数 + + # ---- 因子研究参数 ---- + factor_windows: List[int] = field(default_factory=lambda: [5, 10, 20, 60]) + ic_decay_days: int = 20 # IC 衰减分析天数 + + # ---- 输出 ---- + output_dir: str = "./output" + save_trade_log: bool = True + verbose: bool = False \ No newline at end of file diff --git a/alpha/evaluation.py b/alpha/evaluation.py new file mode 100644 index 0000000..64d4674 --- /dev/null +++ b/alpha/evaluation.py @@ -0,0 +1,262 @@ +""" +评估与报告模块 — 绩效评估指标、风险分析、可视化报告 +""" +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd + + +# --------------------------------------------------------------------------- +# 绩效评估器 +# --------------------------------------------------------------------------- + +class PerformanceEvaluator: + """ + 绩效评估器 — 计算常见量化指标 + """ + + def __init__( + self, + equity_curve: pd.DataFrame, + benchmark_returns: Optional[pd.Series] = None, + risk_free_rate: float = 0.03, + ): + """ + equity_curve : DataFrame, 必须包含 'return' 或 'nav' 列, index=date + benchmark_returns : Series, 基准日收益率, index=date + risk_free_rate : 年化无风险利率 + """ + self.equity = equity_curve.copy() + self.rf = risk_free_rate + + # 确保有日收益率列 + if "daily_return" not in self.equity.columns: + if "return" in self.equity.columns: + self.equity["daily_return"] = self.equity["return"].diff().fillna(0) + elif "nav" in self.equity.columns: + self.equity["daily_return"] = self.equity["nav"].pct_change().fillna(0) + else: + raise ValueError("equity_curve 必须包含 'return'/'nav' 列") + + self.benchmark_returns = benchmark_returns + self._metrics: Dict[str, float] = {} + + # ----- 基础指标 ----- + + def total_return(self) -> float: + """累计收益率""" + if "nav" in self.equity.columns: + return self.equity["nav"].iloc[-1] - 1.0 + return (1 + self.equity["daily_return"]).prod() - 1.0 + + def annual_return(self, periods_per_year: int = 252) -> float: + """年化收益率""" + total = self.total_return() + years = self._year_frac(periods_per_year) + return (1 + total) ** (1 / years) - 1.0 if years > 0 else 0.0 + + def annual_volatility(self, periods_per_year: int = 252) -> float: + """年化波动率""" + return self.equity["daily_return"].std() * np.sqrt(periods_per_year) + + def max_drawdown(self) -> float: + """最大回撤""" + if "nav" in self.equity.columns: + nav = self.equity["nav"] + else: + nav = (1 + self.equity["daily_return"]).cumprod() + running_max = nav.cummax() + drawdown = (nav - running_max) / running_max + return drawdown.min() + + def max_drawdown_duration(self) -> int: + """最长回撤持续天数""" + if "nav" in self.equity.columns: + nav = self.equity["nav"] + else: + nav = (1 + self.equity["daily_return"]).cumprod() + running_max = nav.cummax() + is_dd = nav < running_max + # 统计最长连续 True 段 + dd_groups = (is_dd != is_dd.shift()).cumsum() + dd_durations = ( + is_dd.groupby(dd_groups).sum() + ) + return int(dd_durations.max()) if not dd_durations.empty else 0 + + # ----- 风险调整收益 ----- + + def sharpe_ratio(self, periods_per_year: int = 252) -> float: + """夏普比率""" + excess = self.equity["daily_return"] - self.rf / periods_per_year + return ( + excess.mean() / (excess.std() + 1e-12) * np.sqrt(periods_per_year) + ) + + def calmar_ratio(self) -> float: + """Calmar 比率 (年化收益 / 最大回撤绝对值)""" + mdd = abs(self.max_drawdown()) + return self.annual_return() / mdd if mdd > 0 else 0.0 + + def sortino_ratio(self, periods_per_year: int = 252) -> float: + """Sortino 比率 (只考虑下行波动)""" + returns = self.equity["daily_return"] + downside = returns[returns < 0] + downside_vol = downside.std() * np.sqrt(periods_per_year) + excess_annual = self.annual_return() - self.rf + return excess_annual / (downside_vol + 1e-12) + + def win_rate(self) -> float: + """胜率(日收益率 > 0 的比例)""" + returns = self.equity["daily_return"] + return (returns > 0).mean() + + def profit_loss_ratio(self) -> float: + """盈亏比 (平均盈利 / 平均亏损绝对值)""" + returns = self.equity["daily_return"] + avg_win = returns[returns > 0].mean() + avg_loss = abs(returns[returns < 0].mean()) + return avg_win / (avg_loss + 1e-12) + + # ----- 相对基准指标 ----- + + def alpha(self, periods_per_year: int = 252) -> float: + """Jensen's Alpha 相对基准""" + if self.benchmark_returns is None: + return 0.0 + aligned = self._align_benchmark() + strat_ret = aligned["strat"] + bench_ret = aligned["bench"] + excess_strat = strat_ret - self.rf / periods_per_year + excess_bench = bench_ret - self.rf / periods_per_year + # 线性回归 + cov = np.cov(excess_strat, excess_bench) + beta = cov[0, 1] / (cov[1, 1] + 1e-12) + alpha_daily = excess_strat.mean() - beta * excess_bench.mean() + return alpha_daily * periods_per_year + + def beta(self) -> float: + """Beta""" + if self.benchmark_returns is None: + return 1.0 + aligned = self._align_benchmark() + cov = np.cov(aligned["strat"], aligned["bench"]) + return cov[0, 1] / (cov[1, 1] + 1e-12) + + def information_ratio(self, periods_per_year: int = 252) -> float: + """信息比率""" + if self.benchmark_returns is None: + return 0.0 + aligned = self._align_benchmark() + tracking_error = (aligned["strat"] - aligned["bench"]).std() + return ( + (aligned["strat"].mean() - aligned["bench"].mean()) + / (tracking_error + 1e-12) + * np.sqrt(periods_per_year) + ) + + # ----- 报告 ----- + + def full_report(self) -> Dict[str, float]: + """生成完整绩效报告字典""" + self._metrics = { + "累计收益率(%)": self.total_return() * 100, + "年化收益率(%)": self.annual_return() * 100, + "年化波动率(%)": self.annual_volatility() * 100, + "最大回撤(%)": self.max_drawdown() * 100, + "最长回撤天数": self.max_drawdown_duration(), + "夏普比率": self.sharpe_ratio(), + "Calmar比率": self.calmar_ratio(), + "Sortino比率": self.sortino_ratio(), + "日胜率(%)": self.win_rate() * 100, + "盈亏比": self.profit_loss_ratio(), + "Alpha(%)": self.alpha() * 100, + "Beta": self.beta(), + "信息比率": self.information_ratio(), + } + return self._metrics + + def summary(self) -> str: + """打印可读报告""" + report = self.full_report() + lines = ["=" * 50, " 量化策略绩效评估报告", "=" * 50] + for key, value in report.items(): + if isinstance(value, float): + lines.append(f" {key}: {value:.4f}") + else: + lines.append(f" {key}: {value}") + lines.append("=" * 50) + return "\n".join(lines) + + # ----- 内部方法 ----- + + def _year_frac(self, periods_per_year: int) -> float: + n = len(self.equity) + return n / periods_per_year + + def _align_benchmark(self) -> pd.DataFrame: + """对齐策略与基准日收益率""" + strat = self.equity["daily_return"] + bench = self.benchmark_returns + merged = pd.concat([strat, bench], axis=1, join="inner").dropna() + merged.columns = ["strat", "bench"] + return merged + + +# --------------------------------------------------------------------------- +# 报告生成器 +# --------------------------------------------------------------------------- + +class ReportGenerator: + """生成交易分析报告(Markdown / Excel)""" + + def __init__(self, evaluator: PerformanceEvaluator, trades: Optional[List] = None): + self.evaluator = evaluator + self.trades = trades or [] + + def to_markdown(self) -> str: + """输出 Markdown 格式报告""" + metrics = self.evaluator.full_report() + lines = [ + "# 量化策略回测报告", + "", + "## 绩效指标", + "", + "| 指标 | 数值 |", + "|------|------|", + ] + for key, value in metrics.items(): + lines.append(f"| {key} | {value:.4f} |") + + # 交易统计 + if self.trades: + lines.extend(["", "## 交易统计", ""]) + trades_df = pd.DataFrame( + [ + { + "date": t.date, + "stock": t.stock, + "side": t.side, + "quantity": t.quantity, + "price": t.price, + } + for t in self.trades + ] + ) + total_trades = len(trades_df) + buy_trades = len(trades_df[trades_df["side"] == "buy"]) + sell_trades = len(trades_df[trades_df["side"] == "sell"]) + lines.append(f"- 总交易笔数: {total_trades}") + lines.append(f"- 买入笔数: {buy_trades}") + lines.append(f"- 卖出笔数: {sell_trades}") + + return "\n".join(lines) + + def to_dataframe(self) -> pd.DataFrame: + """将绩效指标导出为 DataFrame""" + metrics = self.evaluator.full_report() + return pd.DataFrame( + list(metrics.items()), columns=["指标", "数值"] + ).set_index("指标") \ No newline at end of file diff --git a/alpha/factors.py b/alpha/factors.py new file mode 100644 index 0000000..b5cc6c8 --- /dev/null +++ b/alpha/factors.py @@ -0,0 +1,256 @@ +""" +因子研究模块 — 因子定义、计算、注册与 IC/分层分析 +""" +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd +from scipy import stats + + +# --------------------------------------------------------------------------- +# 因子注册表 +# --------------------------------------------------------------------------- + +class FactorRegistry: + """因子注册表,用于管理和发现所有因子""" + + _factors: Dict[str, type] = {} + + @classmethod + def register(cls, factor_cls: type): + """注册因子类""" + name = factor_cls.__name__ + cls._factors[name] = factor_cls + return factor_cls + + @classmethod + def get(cls, name: str) -> Optional[type]: + """根据名称获取因子类""" + return cls._factors.get(name) + + @classmethod + def list_all(cls) -> List[str]: + """列出所有已注册因子""" + return sorted(cls._factors.keys()) + + @classmethod + def create(cls, name: str, **kwargs): + """根据名称创建因子实例""" + factor_cls = cls._factors.get(name) + if factor_cls is None: + raise KeyError(f"因子 '{name}' 未注册,可用: {cls.list_all()}") + return factor_cls(**kwargs) + + +# --------------------------------------------------------------------------- +# 因子基类 +# --------------------------------------------------------------------------- + +class BaseFactor(ABC): + """因子抽象基类""" + + name: str = "BaseFactor" + category: str = "unknown" # technical / fundamental / alternative + + def __init__(self, **params): + self.params = params + + @abstractmethod + def compute(self, data: pd.DataFrame) -> pd.Series: + """计算因子值,返回 Series(index=timestamp) 或多索引 Series""" + ... + + def __repr__(self): + return f"{self.name}({self.params})" + + def neutralize(self, + factor: pd.Series, + group: pd.Series, + market_cap: pd.Series) -> pd.Series: + """ + 因子中性化:对市值和行业做正交化处理。 + factor : 原始因子值 + group : 行业分组 + market_cap : 市值 + 返回中性化后的因子值。 + """ + # 创建虚拟变量并拟合线性模型,取残差作为中性化因子 + df = pd.DataFrame({ + "factor": factor, + "log_mcap": np.log(market_cap.clip(lower=1)), + }) + dummies = pd.get_dummies(group, prefix="group") + X = pd.concat([df["log_mcap"], dummies], axis=1) + # 对齐索引 + common_idx = factor.index.intersection(X.dropna().index) + X = X.loc[common_idx] + y = factor.loc[common_idx] + # OLS 回归取残差 + beta = np.linalg.lstsq(X.values, y.values, rcond=None)[0] + pred = X.values @ beta + residual = y.values - pred + neutralized = pd.Series(residual, index=common_idx, name=factor.name) + return neutralized + + +# --------------------------------------------------------------------------- +# 技术面因子 +# --------------------------------------------------------------------------- + +@FactorRegistry.register +class TechnicalFactor(BaseFactor): + """通用技术面因子 — 支持自定义参数组合""" + + name = "TechnicalFactor" + category = "technical" + + def compute(self, data: pd.DataFrame) -> pd.Series: + """ + data 必须包含: ['close', 'volume', 'high', 'low'] + 返回: 标准化后的多因子合成值 + """ + # 示例因子: + # 1) 动量因子 (20日收益率) + # 2) 波动率因子 (20日波动率倒数) + close = data["close"] + mom = close.pct_change(self.params.get("mom_window", 20)) + vol = close.pct_change().rolling(self.params.get("vol_window", 20)).std() + + # 合成 (等权,可按需要改为 ICIR 加权) + mom_z = (mom - mom.mean()) / mom.std() + vol_z = -(vol - vol.mean()) / vol.std() # 低波为正 + composite = 0.5 * mom_z + 0.5 * vol_z + return composite.rename("TechnicalFactor") + + +# --------------------------------------------------------------------------- +# 基本面因子 +# --------------------------------------------------------------------------- + +@FactorRegistry.register +class FundamentalFactor(BaseFactor): + """通用基本面因子 — ROE / PE / PB / 营收增速等""" + + name = "FundamentalFactor" + category = "fundamental" + + def compute(self, data: pd.DataFrame) -> pd.Series: + """ + data 应包含: ['roe', 'pe', 'pb', 'revenue_growth'] + 返回基本面复合因子 + """ + roe = data.get("roe", None) + pe = data.get("pe", None) + pb = data.get("pb", None) + growth = data.get("revenue_growth", None) + + # 将各因子标准化后等权合成 + scores = [] + for series, inv in [(roe, False), (pe, True), (pb, True), (growth, False)]: + if series is not None: + z = (series - series.mean()) / series.std() + scores.append(-z if inv else z) + + if not scores: + raise ValueError("无可用基本面数据列") + + composite = sum(scores) / len(scores) + return composite.rename("FundamentalFactor") + + +# --------------------------------------------------------------------------- +# IC / 分层分析工具 +# --------------------------------------------------------------------------- + +class FactorAnalyzer: + """因子分析器 — IC 计算、分层回测、换手率分析""" + + def __init__(self, factor_values: pd.Series, forward_returns: pd.Series): + """ + factor_values : 因子值 Series (MultiIndex: [date, code] 或 对齐 index) + forward_returns : 未来一期收益率,与 factor 对齐 + """ + self.factor = factor_values + self.forward = forward_returns + self.ic_series: Optional[pd.Series] = None + + def compute_ic(self, method: str = "rank") -> pd.Series: + """逐截面计算 IC (Rank IC 或 Pearson IC)""" + if isinstance(self.factor.index, pd.MultiIndex): + grouped = self.factor.groupby(level=0) + fwd = self.forward.groupby(level=0) + if method == "rank": + ic_data = grouped.apply( + lambda g: g.corr(fwd.get_group(g.name), method="spearman") + ) + else: + ic_data = grouped.apply( + lambda g: g.corr(fwd.get_group(g.name), method="pearson") + ) + else: + # 单截面 + if method == "rank": + ic_data = pd.Series( + stats.spearmanr(self.factor, self.forward)[0], + index=[self.factor.index[0] if len(self.factor) > 0 else 0], + ) + else: + ic_data = pd.Series( + stats.pearsonr(self.factor, self.forward)[0], + index=[self.factor.index[0] if len(self.factor) > 0 else 0], + ) + self.ic_series = ic_data.dropna() + return self.ic_series + + def ic_summary(self) -> Dict[str, float]: + """IC 汇总统计""" + if self.ic_series is None: + self.compute_ic() + ic = self.ic_series + return { + "IC_Mean": ic.mean(), + "IC_Std": ic.std(), + "IR": ic.mean() / (ic.std() + 1e-12), + "IC>0_Ratio": (ic > 0).mean(), + "IC_Abs_Mean": ic.abs().mean(), + } + + def ic_decay(self, forward_returns: Dict[int, pd.Series]) -> pd.Series: + """IC 衰减: 计算不同前瞻期的 IC 均值""" + decay = {} + for horizon, fwd in forward_returns.items(): + ratio = fwd.reindex(self.forward.index) + valid = self.factor.notna() & ratio.notna() + ic = stats.spearmanr( + self.factor[valid].values, ratio[valid].values + )[0] + decay[horizon] = ic + return pd.Series(decay, name="IC_Decay") + + def quantile_returns(self, n_quantiles: int = 5) -> pd.DataFrame: + """分层回测:按因子值分 5 组,计算各组平均收益率""" + df = pd.DataFrame({"factor": self.factor, "fwd": self.forward}).dropna() + df["quantile"] = pd.qcut(df["factor"], n_quantiles, labels=False) + 1 + result = df.groupby("quantile")["fwd"].mean().to_frame("avg_return") + result.index.name = "quantile" + result["cum_return"] = result["avg_return"].cumsum() + return result + + def turnover(self, n_quantiles: int = 5) -> pd.Series: + """因子换手率分析(相邻期分位数变化比例)""" + df = pd.DataFrame({"factor": self.factor}) + df["quantile"] = pd.qcut(df["factor"], n_quantiles, labels=False) + 1 + if isinstance(df.index, pd.MultiIndex): + df = df.reset_index() + date_col = df.columns[0] + turnover_list = [] + for date, grp in df.groupby(date_col): + pass # 此处需按股票计算 --- 简化处理 + # 简化: 逐期计算因子自相关系数 + if isinstance(self.ic_series, pd.Series): + auto_corr = self.factor.autocorr(lag=1) + return pd.Series({"auto_corr": auto_corr}) + return pd.Series() \ No newline at end of file diff --git a/alpha/requirements.txt b/alpha/requirements.txt new file mode 100644 index 0000000..5b0e498 --- /dev/null +++ b/alpha/requirements.txt @@ -0,0 +1,6 @@ +numpy>=1.24.0 +pandas>=2.0.0 +scipy>=1.10.0 +matplotlib>=3.7.0 +tushare>=1.3.0 +psycopg2-binary>=2.9.0 \ No newline at end of file diff --git a/alpha/strategy.py b/alpha/strategy.py new file mode 100644 index 0000000..08e8a24 --- /dev/null +++ b/alpha/strategy.py @@ -0,0 +1,254 @@ +""" +策略模块 — 信号生成、权重分配、组合构建 +""" +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Set, Tuple + +import numpy as np +import pandas as pd + + +# --------------------------------------------------------------------------- +# 信号生成器 +# --------------------------------------------------------------------------- + +class SignalGenerator(ABC): + """信号生成器基类 — 将因子值转换为交易信号""" + + @abstractmethod + def generate(self, factor_df: pd.DataFrame, **kwargs) -> pd.DataFrame: + """ + factor_df: DataFrame, index=date, columns=stock_codes, values=factor值 + 返回信号 DataFrame,同结构,1=做多, 0=平仓, -1=做空 + """ + ... + + +class QuantileSignal(SignalGenerator): + """基于因子分位数的信号生成器""" + + def __init__( + self, + n_quantiles: int = 5, + long_quantile: int = 5, + short_quantile: int = 1, + min_stocks: int = 10, + ): + self.n_quantiles = n_quantiles + self.long_quantile = long_quantile + self.short_quantile = short_quantile + self.min_stocks = min_stocks + + def generate(self, factor_df: pd.DataFrame, **kwargs) -> pd.DataFrame: + """ + 逐截面按因子值分组: + long_quantile 档 → +1 + short_quantile 档 → -1 + 其余 → 0 + """ + signal = pd.DataFrame(0, index=factor_df.index, columns=factor_df.columns) + + for date in factor_df.index: + row = factor_df.loc[date].dropna() + if len(row) < self.min_stocks: + continue + try: + bins = pd.qcut( + row, self.n_quantiles, labels=False, duplicates="drop" + ) + except ValueError: + continue + signal.loc[date, bins == self.long_quantile - 1] = 1 + signal.loc[date, bins == self.short_quantile - 1] = -1 + + return signal + + +class ZScoreSignal(SignalGenerator): + """基于 Z-Score 阈值的信号生成器""" + + def __init__(self, entry_z: float = 1.0, exit_z: float = 0.5): + self.entry_z = entry_z + self.exit_z = exit_z + + def generate(self, factor_df: pd.DataFrame, **kwargs) -> pd.DataFrame: + """ + 因子截面标准化(Z-Score): + > entry_z → +1 + < -entry_z → -1 + 其余 → 0 + """ + signal = pd.DataFrame(0, index=factor_df.index, columns=factor_df.columns) + for date in factor_df.index: + row = factor_df.loc[date] + valid = row.notna() + if valid.sum() < 3: + continue + z = (row - row[valid].mean()) / (row[valid].std() + 1e-12) + signal.loc[date, z > self.entry_z] = 1 + signal.loc[date, z < -self.entry_z] = -1 + return signal + + +# --------------------------------------------------------------------------- +# 权重分配器 +# --------------------------------------------------------------------------- + +class WeightAllocator(ABC): + """权重分配器基类""" + + @abstractmethod + def allocate( + self, + signals: pd.DataFrame, + prices: pd.DataFrame, + cash: float, + positions: Dict[str, int], + **kwargs, + ) -> Dict[str, float]: + """ + 返回目标权重字典 {stock_code: weight} + weight 为目标持仓市值占比 (0~1) + """ + ... + + +class EqualWeightAllocator(WeightAllocator): + """等权分配器""" + + def __init__(self, max_positions: int = 30): + self.max_positions = max_positions + + def allocate( + self, + signals: pd.DataFrame, + prices: pd.DataFrame, + cash: float, + positions: Dict[str, int], + **kwargs, + ) -> Dict[str, float]: + latest_signal = signals.iloc[-1] if len(signals) > 0 else pd.Series(dtype=float) + long_stocks = latest_signal[latest_signal > 0].index.tolist() + + if not long_stocks: + return {} + + # 限制持仓数 + long_stocks = long_stocks[: self.max_positions] + weight = 1.0 / len(long_stocks) + return {s: weight for s in long_stocks} + + +class FactorWeightAllocator(WeightAllocator): + """因子值加权分配器 — 因子越大权重越大""" + + def __init__(self, max_positions: int = 30, min_weight: float = 0.005): + self.max_positions = max_positions + self.min_weight = min_weight + + def allocate( + self, + signals: pd.DataFrame, + prices: pd.DataFrame, + cash: float, + positions: Dict[str, int], + factor_df: Optional[pd.DataFrame] = None, + **kwargs, + ) -> Dict[str, float]: + if factor_df is None: + return EqualWeightAllocator(self.max_positions).allocate( + signals, prices, cash, positions + ) + + latest_signal = signals.iloc[-1] if len(signals) > 0 else pd.Series(dtype=float) + latest_factor = factor_df.iloc[-1] if len(factor_df) > 0 else pd.Series(dtype=float) + + long_stocks = latest_signal[latest_signal > 0].index + # 按因子值排序 + valid = latest_factor[long_stocks].dropna().sort_values(ascending=False) + selected = valid.head(self.max_positions) + if selected.empty: + return {} + + raw_w = selected.values / (selected.values.sum() + 1e-12) + raw_w = np.clip(raw_w, self.min_weight, 1.0) + raw_w = raw_w / raw_w.sum() + return dict(zip(selected.index.tolist(), raw_w.tolist())) + + +# --------------------------------------------------------------------------- +# 策略上下文 +# --------------------------------------------------------------------------- + +@dataclass +class Strategy: + """ + 量化策略 — 组合因子、信号生成器、权重分配器,定义一个完整策略。 + """ + + name: str + factors: List # 因子对象列表 + signal_generator: SignalGenerator + weight_allocator: WeightAllocator + factor_weights: Optional[Dict[str, float]] = None # 因子合成权重 + filter_universe: Optional[Set[str]] = None # 可选股票池 + description: str = "" + + def compute_composite_factor(self, factor_data: Dict[str, pd.DataFrame]) -> pd.DataFrame: + """ + 合成多因子值。 + factor_data: {factor_name: DataFrame(index=date, columns=stocks)} + """ + if not factor_data: + raise ValueError("factor_data 为空") + + # 统一列(股票池)合并 + all_stocks = sorted( + set().union(*[set(df.columns) for df in factor_data.values()]) + ) + all_dates = sorted( + set().union(*[set(df.index) for df in factor_data.values()]) + ) + + composite = pd.DataFrame(0.0, index=all_dates, columns=all_stocks) + n_factors = len(factor_data) + + for name, df in factor_data.items(): + w = ( + self.factor_weights.get(name, 1.0 / n_factors) + if self.factor_weights + else 1.0 / n_factors + ) + aligned = df.reindex(index=all_dates, columns=all_stocks) + # 截面标准化 + z = aligned.sub(aligned.mean(axis=1), axis=0).div( + aligned.std(axis=1) + 1e-12, axis=0 + ) + composite += w * z + + return composite + + def run_step( + self, + date: pd.Timestamp, + factor_data: Dict[str, pd.DataFrame], + prices: pd.DataFrame, + cash: float, + positions: Dict[str, int], + ) -> Dict[str, float]: + """ + 单步执行:生成信号 → 分配权重 → 返回目标持仓权重。 + """ + composite = self.compute_composite_factor(factor_data) + # 仅取当前日期截面 + if date in composite.index: + current_slice = composite.loc[[date]] + else: + current_slice = composite.iloc[-1:] + + signals = self.signal_generator.generate(current_slice) + weights = self.weight_allocator.allocate( + signals, prices, cash, positions, factor_df=composite + ) + return weights \ No newline at end of file diff --git a/alpha/阿尔法策略研究示例.ipynb b/alpha/阿尔法策略研究示例.ipynb new file mode 100644 index 0000000..c9eb411 --- /dev/null +++ b/alpha/阿尔法策略研究示例.ipynb @@ -0,0 +1,279 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 阿尔法策略回测 — 完整示例\n", + "\n", + "本 Notebook 演示如何使用 `quanxiel.alpha` 模块进行:\n", + "1. 因子计算与 IC 分析\n", + "2. 策略信号生成\n", + "3. 事件驱动回测\n", + "4. 绩效评估" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "sys.path.insert(0, '..')\n", + "\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from alpha.config import AlphaConfig\n", + "from alpha.factors import FactorRegistry, FactorAnalyzer\n", + "from alpha.strategy import Strategy, QuantileSignal, EqualWeightAllocator\n", + "from alpha.backtest import BacktestEngine\n", + "from alpha.evaluation import PerformanceEvaluator, ReportGenerator\n", + "\n", + "%matplotlib inline\n", + "plt.rcParams['font.sans-serif'] = ['SimHei']\n", + "plt.rcParams['axes.unicode_minus'] = False" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. 准备数据\n", + "\n", + "从本地数据库或 Tushare 获取量价数据与财务数据。这里用随机数据演示流程。" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# ---- 生成示例数据 -------\n", + "np.random.seed(42)\n", + "dates = pd.date_range('2022-01-01', '2024-12-31', freq='B')\n", + "stocks = [f'{i:06d}.SH' for i in range(600000, 600050)]\n", + "\n", + "# 价格数据\n", + "price_data = pd.DataFrame(\n", + " np.cumprod(1 + np.random.randn(len(dates), len(stocks)) * 0.02, axis=0),\n", + " index=dates, columns=stocks\n", + ")\n", + "\n", + "# 因子数据(模拟技术面因子值)\n", + "factor_df = pd.DataFrame(\n", + " np.random.randn(len(dates), len(stocks)),\n", + " index=dates, columns=stocks\n", + ")\n", + "\n", + "print(f'价格数据: {price_data.shape}')\n", + "print(f'因子数据: {factor_df.shape}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. 因子 IC 分析" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 计算未来 1 期收益率\n", + "fwd_returns = price_data.pct_change().shift(-1).stack()\n", + "factor_stacked = factor_df.stack()\n", + "\n", + "analyzer = FactorAnalyzer(factor_stacked, fwd_returns)\n", + "\n", + "# Rank IC\n", + "ic = analyzer.compute_ic(method='rank')\n", + "print('IC 汇总:')\n", + "for k, v in analyzer.ic_summary().items():\n", + " print(f' {k}: {v:.4f}')\n", + "\n", + "# IC 曲线\n", + "ic.plot(figsize=(12, 3), title='Rank IC 时间序列')\n", + "plt.axhline(y=0, color='r', linestyle='--')\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. 分层回测" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "quantile_ret = analyzer.quantile_returns(n_quantiles=5)\n", + "print('各分位组平均收益:')\n", + "print(quantile_ret)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. 构建策略 & 回测" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# 配置\n", + "config = AlphaConfig(\n", + " initial_cash=1_000_000,\n", + " commission_rate=0.0003,\n", + " slippage=0.001,\n", + " stamp_tax=0.001,\n", + ")\n", + "\n", + "# 策略组合\n", + "strategy = Strategy(\n", + " name='技术因子-分位数策略',\n", + " factors=[],\n", + " signal_generator=QuantileSignal(n_quantiles=5, long_quantile=5, short_quantile=1),\n", + " weight_allocator=EqualWeightAllocator(max_positions=20),\n", + " description='买入因子值最高的分位组,等权持仓'\n", + ")\n", + "\n", + "# 回测引擎\n", + "engine = BacktestEngine(config)\n", + "equity_curve = engine.run(\n", + " strategy=strategy,\n", + " price_data=price_data,\n", + " factor_data={'technical': factor_df},\n", + " rebalance_freq='M', # 月频调仓\n", + ")\n", + "\n", + "print(f'回测完成, 共 {len(equity_curve)} 个交易日')\n", + "print(f'累计收益率: {(equity_curve[\"nav\"].iloc[-1] - 1) * 100:.2f}%')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. 净值曲线" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fig, axes = plt.subplots(2, 1, figsize=(14, 8))\n", + "\n", + "# 净值曲线\n", + "axes[0].plot(equity_curve.index, equity_curve['nav'], label='策略净值', color='steelblue')\n", + "axes[0].axhline(y=1.0, color='gray', linestyle='--')\n", + "axes[0].set_title('策略净值曲线')\n", + "axes[0].legend()\n", + "axes[0].grid(True, alpha=0.3)\n", + "\n", + "# 回撤曲线\n", + "nav = equity_curve['nav']\n", + "running_max = nav.cummax()\n", + "drawdown = (nav - running_max) / running_max\n", + "axes[1].fill_between(equity_curve.index, 0, drawdown, color='red', alpha=0.3)\n", + "axes[1].set_title('回撤曲线')\n", + "axes[1].grid(True, alpha=0.3)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. 绩效评估" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "evaluator = PerformanceEvaluator(equity_curve, risk_free_rate=0.03)\n", + "print(evaluator.summary())\n", + "\n", + "# 导出报告 DataFrame\n", + "report_df = evaluator.full_report()\n", + "pd.DataFrame(list(report_df.items()), columns=['指标', '数值']).set_index('指标')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. 交易记录分析" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "trades = engine.portfolio.trades\n", + "if trades:\n", + " trade_df = pd.DataFrame([\n", + " {'日期': t.date, '股票': t.stock, '方向': t.side,\n", + " '数量': t.quantity, '价格': t.price, '佣金': t.commission}\n", + " for t in trades\n", + " ])\n", + " print(f'总交易数: {len(trade_df)}')\n", + " print(f'买入: {(trade_df[\"方向\"]==\"buy\").sum()}, 卖出: {(trade_df[\"方向\"]==\"sell\").sum()}')\n", + " display(trade_df.head(20))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "## 使用真实数据\n", + "\n", + "将以上模拟数据替换为从 `quanxiel.quantitative_data` 模块加载的真实行情:\n", + "\n", + "```python\n", + "from quantitative_data.importer import DataImporter\n", + "\n", + "importer = DataImporter()\n", + "price_data = importer.load_daily_prices('2020-01-01', '2024-12-31')\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file