feat:添加alpha模块
This commit is contained in:
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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("指标")
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user