Compare commits

...
2 Commits
12 changed files with 1587 additions and 14 deletions
+26
View File
@@ -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",
]
+401
View File
@@ -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
+42
View File
@@ -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
+262
View File
@@ -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("指标")
+256
View File
@@ -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()
+6
View File
@@ -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
+254
View File
@@ -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
+279
View File
@@ -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
}
+19
View File
@@ -0,0 +1,19 @@
# ========================================
# 量化数据库 — 环境变量配置模板
# 复制此文件为 .env 并填入真实值
# ========================================
# PostgreSQL 数据库连接
QUANT_DB_HOST=192.168.27.15
QUANT_DB_PORT=12345
QUANT_DB_NAME=quant_db
QUANT_DB_USER=postgres
QUANT_DB_PASSWORD=your_password_here
# Tushare API Token (在 https://tushare.pro 获取)
TUSHARE_TOKEN=your_tushare_token_here
# 批量导入参数 (可选,已有默认值)
# QUANT_BATCH_SIZE=5000
# QUANT_START_DATE=2010-01-01
# QUANT_END_DATE=2025-12-31
+17
View File
@@ -0,0 +1,17 @@
# 环境变量文件 (包含敏感信息,不要提交到版本控制)
.env
# Python
__pycache__/
*.py[cod]
*.egg-info/
# 日志文件
*.log
# Jupyter
.ipynb_checkpoints/
# IDE
.vscode/
.idea/
+20 -9
View File
@@ -1,19 +1,30 @@
"""
量化数据库配置文件
敏感信息(数据库密码、API Token)通过环境变量管理:
- QUANT_DB_PASSWORD : 数据库密码
- TUSHARE_TOKEN : Tushare API Token
设置方式:
Windows: set QUANT_DB_PASSWORD=your_password && set TUSHARE_TOKEN=your_token
Linux: export QUANT_DB_PASSWORD=your_password && export TUSHARE_TOKEN=your_token
也可创建 .env 文件(参考 .env.example
"""
import os
# PostgreSQL Docker 连接配置
DB_CONFIG = {
"host": "192.168.27.15",
"port": 12345,
"database": "quant_db",
"user": "postgres",
"password": "postgres", # 请修改为实际密码
"host": os.environ.get("QUANT_DB_HOST", "192.168.27.15"),
"port": int(os.environ.get("QUANT_DB_PORT", "12345")),
"database": os.environ.get("QUANT_DB_NAME", "quant_db"),
"user": os.environ.get("QUANT_DB_USER", "postgres"),
"password": os.environ.get("QUANT_DB_PASSWORD", ""),
}
# Tushare API Token
TUSHARE_TOKEN = "你的token" # 请替换为你的tushare token
TUSHARE_TOKEN = os.environ.get("TUSHARE_TOKEN", "")
# 批量导入参数
BATCH_SIZE = 5000 # 每批次插入行数
START_DATE = "2010-01-01" # 数据起始日期
END_DATE = "2025-12-31" # 数据结束日期
BATCH_SIZE = int(os.environ.get("QUANT_BATCH_SIZE", "5000")) # 每批次插入行数
START_DATE = os.environ.get("QUANT_START_DATE", "2010-01-01") # 数据起始日期
END_DATE = os.environ.get("QUANT_END_DATE", "2025-12-31") # 数据结束日期
+5 -5
View File
@@ -26,8 +26,8 @@
"| index_daily | 指数日线行情 | index_daily |\n",
"\n",
"## 使用步骤\n",
"1. 修改 `config.py` 中的数据库密码和 Tushare Token\n",
"2. 逐 Cell 运行本 Notebook"
"1. 复制 `.env.example` 为 `.env`,填入真实密码和 Token\n",
"2. 逐 Cell 运行本 Notebook(敏感信息通过环境变量读取)"
]
},
{
@@ -124,7 +124,7 @@
" print(f\"✗ 连接失败: {e}\")\n",
" print(\"请检查:\")\n",
" print(\" 1. Docker 容器是否已启动: docker ps | findstr postgres\")\n",
" print(\" 2. config.py 中的连接参数是否正确\")\n",
" print(\" 2. 环境变量 (.env) 中的连接参数是否正确\")\n",
" print(\" 3. 防火墙是否开放 12345 端口\")"
]
},
@@ -143,7 +143,7 @@
" print(f\" 测试查询: {df.iloc[0].to_dict()}\")\n",
"except Exception as e:\n",
" print(f\"✗ Tushare API 连接失败: {e}\")\n",
" print(\"请检查 config.py 中的 TUSHARE_TOKEN 是否正确\")"
" print(\"请检查环境变量 TUSHARE_TOKEN 是否正确(或 .env 文件)\")"
]
},
{
@@ -214,7 +214,7 @@
"metadata": {},
"outputs": [],
"source": [
"# 导入交易日历 (默认从 config.py 的 START_DATE ~ END_DATE)\n",
"# 导入交易日历 (默认从环境变量 QUANT_START_DATE ~ QUANT_END_DATE)\n",
"import_trade_cal()"
]
},