mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 21:34:21 +08:00
mypy (13 errors → 0): - portfolio/optimizer: register_optimizer 返回类型改为 Callable 装饰器签名 (原标注 type[WeightOptimizer] 导致 4 个子类 Too many arguments) - factor/engine: _datetime_to_int 用 isinstance 收窄替代 object→int 强转 - factor/analysis: 删多余 type:ignore(改由 mypy override 统一处理 scipy) - backtest/orders, execution: np.sqrt 表达式用 float() 包裹消除 no-any-return - MyTT.pyi: MACD 签名删除错误的 LOW/HIGH 参数(与 MyTT.py 实际签名对齐) - pyproject: 新增 scipy mypy override (ignore_missing_imports) ruff format: 8 个 test 文件格式化 验证: 564 passed, mypy 192 文件零错误, ruff check/format 全绿
157 lines
5.6 KiB
Python
157 lines
5.6 KiB
Python
# src/easy_tdx/factor/analysis.py
|
|
"""因子有效性分析引擎。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
|
|
@dataclass
|
|
class FactorReport:
|
|
"""单因子分析报告。"""
|
|
|
|
name: str
|
|
ic_mean: float
|
|
ic_std: float
|
|
ir: float
|
|
ic_positive_rate: float
|
|
quantile_returns: dict[str, float]
|
|
top_minus_bottom: float
|
|
turnover_rate: float
|
|
autocorr: float
|
|
ic_series: pd.Series
|
|
|
|
|
|
class FactorAnalyzer:
|
|
"""因子有效性分析引擎。"""
|
|
|
|
def __init__(
|
|
self,
|
|
factor_data: pd.DataFrame,
|
|
return_data: pd.DataFrame,
|
|
factor_col: str = "momentum_20d",
|
|
return_col: str = "forward_5d",
|
|
n_quantiles: int = 5,
|
|
) -> None:
|
|
self._factor_col = factor_col
|
|
self._return_col = return_col
|
|
self._n_quantiles = n_quantiles
|
|
self._merged = factor_data.merge(
|
|
return_data[["date", "code", return_col]],
|
|
on=["date", "code"],
|
|
how="inner",
|
|
)
|
|
|
|
def compute_ic(self, method: str = "spearman") -> pd.Series:
|
|
"""逐截面计算 Rank IC。"""
|
|
dates = sorted(self._merged["date"].unique())
|
|
ic_values: list[float] = []
|
|
for date in dates:
|
|
sub = self._merged[self._merged["date"] == date]
|
|
valid = sub[[self._factor_col, self._return_col]].dropna()
|
|
if len(valid) < 5:
|
|
ic_values.append(np.nan)
|
|
continue
|
|
if method == "spearman":
|
|
try:
|
|
import scipy # noqa: F401 # pandas spearman lazy import
|
|
except ImportError as e:
|
|
raise ImportError(
|
|
"Rank IC (spearman) 需要 scipy,请执行 `pip install easy-tdx[science]`"
|
|
) from e
|
|
corr = valid[self._factor_col].corr(valid[self._return_col], method="spearman")
|
|
else:
|
|
corr = valid[self._factor_col].corr(valid[self._return_col], method="pearson")
|
|
ic_values.append(corr)
|
|
return pd.Series(ic_values, index=dates, name="IC")
|
|
|
|
def compute_quantile_returns(self) -> pd.DataFrame:
|
|
"""分层收益分析。"""
|
|
dates = sorted(self._merged["date"].unique())
|
|
q_names = [f"q{i + 1}" for i in range(self._n_quantiles)]
|
|
rows: list[list[float]] = []
|
|
for date in dates:
|
|
sub = self._merged[self._merged["date"] == date]
|
|
valid = sub[[self._factor_col, self._return_col]].dropna()
|
|
if len(valid) < self._n_quantiles:
|
|
rows.append([np.nan] * self._n_quantiles)
|
|
continue
|
|
valid = valid.copy()
|
|
valid["_q"] = pd.qcut(
|
|
valid[self._factor_col],
|
|
self._n_quantiles,
|
|
labels=False,
|
|
duplicates="drop",
|
|
)
|
|
means = valid.groupby("_q")[self._return_col].mean()
|
|
rows.append([float(means.get(q, np.nan)) for q in range(self._n_quantiles)])
|
|
return pd.DataFrame(rows, index=dates, columns=q_names)
|
|
|
|
def compute_turnover(self) -> float:
|
|
"""因子换手率。"""
|
|
dates = sorted(self._merged["date"].unique())
|
|
if len(dates) < 2:
|
|
return 0.0
|
|
n_top = max(self._n_quantiles, 5)
|
|
overlaps: list[float] = []
|
|
prev_top: set[str] = set()
|
|
for date in dates:
|
|
sub = self._merged[self._merged["date"] == date]
|
|
valid = sub[[self._factor_col, "code"]].dropna()
|
|
if len(valid) < n_top:
|
|
continue
|
|
top = set(valid.nlargest(n_top, self._factor_col)["code"].tolist())
|
|
if prev_top:
|
|
overlaps.append(len(top & prev_top) / len(top | prev_top))
|
|
prev_top = top
|
|
if not overlaps:
|
|
return 0.0
|
|
return 1.0 - float(np.mean(overlaps))
|
|
|
|
def compute_decay(self, max_lag: int = 10) -> pd.DataFrame:
|
|
"""因子衰减分析。"""
|
|
ic_series = self.compute_ic()
|
|
autocorr_values: list[float] = []
|
|
for lag in range(1, max_lag + 1):
|
|
if lag < len(ic_series):
|
|
ac = ic_series.autocorr(lag=lag)
|
|
autocorr_values.append(ac if not np.isnan(ac) else 0.0)
|
|
else:
|
|
autocorr_values.append(0.0)
|
|
return pd.DataFrame({"lag": range(1, max_lag + 1), "autocorr": autocorr_values})
|
|
|
|
def full_report(self) -> FactorReport:
|
|
"""一键生成完整分析报告。"""
|
|
ic_series = self.compute_ic()
|
|
qr = self.compute_quantile_returns()
|
|
|
|
ic_mean = float(ic_series.mean()) if len(ic_series) > 0 else 0.0
|
|
ic_std = float(ic_series.std()) if len(ic_series) > 1 else 0.0
|
|
ir = ic_mean / ic_std if ic_std > 0 else 0.0
|
|
ic_positive_rate = float((ic_series > 0).mean()) if len(ic_series) > 0 else 0.0
|
|
|
|
quantile_means = qr.mean()
|
|
quantile_returns = {
|
|
f"q{i + 1}": float(quantile_means.iloc[i]) for i in range(len(quantile_means))
|
|
}
|
|
top_minus_bottom = quantile_returns.get("q5", 0.0) - quantile_returns.get("q1", 0.0)
|
|
|
|
turnover = self.compute_turnover()
|
|
autocorr = float(ic_series.autocorr(lag=1)) if len(ic_series) > 1 else 0.0
|
|
|
|
return FactorReport(
|
|
name=self._factor_col,
|
|
ic_mean=ic_mean,
|
|
ic_std=ic_std,
|
|
ir=ir,
|
|
ic_positive_rate=ic_positive_rate,
|
|
quantile_returns=quantile_returns,
|
|
top_minus_bottom=top_minus_bottom,
|
|
turnover_rate=turnover,
|
|
autocorr=autocorr if not np.isnan(autocorr) else 0.0,
|
|
ic_series=ic_series,
|
|
)
|