alpholio 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
alpholio/__init__.py ADDED
@@ -0,0 +1,44 @@
1
+ # 跨截面组合回测工具包:Input → Engine → Analyzer → Visualizer 单向数据流
2
+ from .analyzer import Analyzer
3
+ from .api import BacktestResult, backtest
4
+ from .config_schema import AnalyzerConfig, EngineConfig, InputConfig, PipelineConfig, VisualizerConfig
5
+ from .contracts import AnalysisResult, EngineResult, InputBundle, to_legacy_wide
6
+ from .engine import PortfolioEngine
7
+ from .input import InputProcessor
8
+ from .pipeline import PipelineResult, run_pipeline
9
+ from .settings import settings
10
+ from .visualizer import Visualizer
11
+
12
+ __version__ = "0.4.0"
13
+ __all__ = [
14
+ # 一行跑通的门面入口
15
+ "backtest",
16
+ "BacktestResult",
17
+ "settings",
18
+
19
+ # 核心类
20
+ "InputProcessor",
21
+ "PortfolioEngine",
22
+ "Analyzer",
23
+ "Visualizer",
24
+
25
+ # 辅助函数和数据类
26
+ "run_pipeline",
27
+ "PipelineResult",
28
+ "InputConfig",
29
+ "EngineConfig",
30
+ "AnalyzerConfig",
31
+ "VisualizerConfig",
32
+ "PipelineConfig",
33
+
34
+ # 用于流转的中间产物
35
+ "InputBundle",
36
+ "EngineResult",
37
+ "AnalysisResult",
38
+
39
+ # 格式转换函数
40
+ "to_legacy_wide",
41
+
42
+ # 版本号
43
+ "__version__",
44
+ ]
@@ -0,0 +1,18 @@
1
+ # ③ Analyzer:组合收益 → 指标 / 曲线 / 诊断
2
+ from .analyzer import Analyzer
3
+ from .curves import build_curves, shared_origin, vol_rescale_to_reference
4
+ from .diagnostics import compute_ic, compute_turnover
5
+ from .metrics import METRICS, Metric, MetricContext, build_metric
6
+
7
+ __all__ = [
8
+ "Analyzer",
9
+ "Metric",
10
+ "METRICS",
11
+ "MetricContext",
12
+ "build_metric",
13
+ "compute_turnover",
14
+ "compute_ic",
15
+ "build_curves",
16
+ "shared_origin",
17
+ "vol_rescale_to_reference",
18
+ ]
@@ -0,0 +1,135 @@
1
+ # ③ Analyzer:组合收益 → 指标汇总 + 曲线 + 诊断
2
+ from __future__ import annotations
3
+
4
+ from typing import Dict, List
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from ..config_schema import AnalyzerConfig
10
+ from ..contracts import (
11
+ BUCKET,
12
+ DATE,
13
+ IC,
14
+ N_NAMES,
15
+ RET,
16
+ SIGNAL,
17
+ TURNOVER,
18
+ WEIGHT,
19
+ AnalysisResult,
20
+ ContractError,
21
+ EngineResult,
22
+ sort_by_bucket,
23
+ )
24
+ from ..frequency import DAILY, resolve
25
+ from .curves import build_curves, shared_origin, vol_rescale_to_reference
26
+ from .diagnostics import compute_ic, compute_turnover
27
+ from .metrics import MetricContext, build_metric
28
+
29
+
30
+ class Analyzer:
31
+ def __init__(self, cfg: AnalyzerConfig) -> None:
32
+ self.cfg = cfg
33
+ self.metrics = [build_metric(name) for name in cfg.metrics]
34
+
35
+ # 唯一出口
36
+ def run(self, result: EngineResult) -> AnalysisResult:
37
+ cfg = self.cfg
38
+ ppy = self._periods_per_year(result.meta)
39
+ ctx = MetricContext(periods_per_year=ppy, risk_free_rate=cfg.risk_free_rate)
40
+
41
+ turnover = (
42
+ compute_turnover(
43
+ result.members,
44
+ long_short_label=result.meta.get("long_short_label"),
45
+ long_bucket=result.meta.get("long_bucket"),
46
+ short_bucket=result.meta.get("short_bucket"),
47
+ )
48
+ if cfg.diagnostics.turnover
49
+ else _empty(SIGNAL, BUCKET, DATE, TURNOVER)
50
+ )
51
+ ic = (
52
+ compute_ic(result.aligned, cfg.diagnostics.ic_min_names)
53
+ if cfg.diagnostics.ic
54
+ else _empty(SIGNAL, DATE, IC, N_NAMES)
55
+ )
56
+
57
+ returns = self._rescale(result.returns)
58
+ origin = shared_origin(returns) if cfg.curves.shared_origin else None
59
+
60
+ return AnalysisResult(
61
+ summary=self._summarize(returns, ctx, turnover, ic),
62
+ curves=build_curves(returns, cfg.curves.clip_lower, origin),
63
+ turnover=turnover,
64
+ ic=ic,
65
+ meta={
66
+ "periods_per_year": ppy,
67
+ "metrics": [m.name for m in self.metrics],
68
+ "vol_rescaled": bool(cfg.vol_rescale.enabled),
69
+ "vol_rescale_reference": cfg.vol_rescale.reference if cfg.vol_rescale.enabled else None,
70
+ **{
71
+ k: result.meta[k]
72
+ for k in ("frequency", "holding_days", "n_buckets")
73
+ if k in result.meta
74
+ },
75
+ },
76
+ )
77
+
78
+ # 开关打开时整体替换为缩放后的收益,不再并列输出两套口径
79
+ def _rescale(self, returns: pd.DataFrame) -> pd.DataFrame:
80
+ spec = self.cfg.vol_rescale
81
+ if not spec.enabled:
82
+ return returns
83
+ if not spec.reference:
84
+ raise ContractError(
85
+ "analyzer.vol_rescale.enabled=true 时必须指定 reference(缩放到哪个基准)"
86
+ )
87
+ return vol_rescale_to_reference(returns, spec.reference, spec.min_periods)
88
+
89
+ # 年化因子:显式配置优先,否则由「年化基数 / 持有期」推导。
90
+ # 基数随面板频率:日频取 analyzer.trading_days_per_year(默认 252),月频取 12。
91
+ # 频率沿 InputBundle → EngineResult.meta 传来,缺省按日频处理。
92
+ def _periods_per_year(self, engine_meta: Dict) -> float:
93
+ if self.cfg.periods_per_year:
94
+ return float(self.cfg.periods_per_year)
95
+ frequency = resolve(engine_meta.get("frequency", DAILY))
96
+ base = (
97
+ frequency.bars_per_year
98
+ if frequency.is_monthly
99
+ else float(self.cfg.trading_days_per_year)
100
+ )
101
+ holding = int(engine_meta.get("holding_days", 1))
102
+ return base / max(holding, 1)
103
+
104
+ def _summarize(
105
+ self, returns: pd.DataFrame, ctx: MetricContext, turnover: pd.DataFrame, ic: pd.DataFrame
106
+ ) -> pd.DataFrame:
107
+ turnover_mean = _mean_by(turnover, [SIGNAL, BUCKET], TURNOVER)
108
+ ic_mean = _mean_by(ic, [SIGNAL], IC)
109
+ rows: List[Dict] = []
110
+ for (signal, bucket, weight), grp in returns.groupby(
111
+ [SIGNAL, BUCKET, WEIGHT], sort=True
112
+ ):
113
+ series = grp[RET].dropna().to_numpy(dtype=np.float64)
114
+ if len(series) == 0:
115
+ continue
116
+ row = {
117
+ SIGNAL: signal, BUCKET: bucket, WEIGHT: weight,
118
+ "n_periods": int(len(series)),
119
+ }
120
+ row.update({m.name: m.compute(series, ctx) for m in self.metrics})
121
+ row[TURNOVER] = turnover_mean.get((signal, bucket), np.nan)
122
+ row["ic_mean"] = ic_mean.get(signal, np.nan)
123
+ rows.append(row)
124
+ return sort_by_bucket(pd.DataFrame(rows), [SIGNAL, WEIGHT])
125
+
126
+
127
+ def _empty(*columns: str) -> pd.DataFrame:
128
+ return pd.DataFrame(columns=list(columns))
129
+
130
+
131
+ def _mean_by(frame: pd.DataFrame, keys: List[str], column: str) -> Dict:
132
+ if frame.empty or column not in frame.columns:
133
+ return {}
134
+ grouped = frame.groupby(keys[0] if len(keys) == 1 else keys, sort=False)[column].mean()
135
+ return grouped.to_dict()
@@ -0,0 +1,85 @@
1
+ # 净值 / 累计对数收益曲线,以及对基准的波动率缩放
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from ..contracts import (
10
+ BUCKET,
11
+ CUM_LOG_RET,
12
+ DATE,
13
+ EQUITY,
14
+ REFERENCE_BUCKET,
15
+ RET,
16
+ SIGNAL,
17
+ WEIGHT,
18
+ )
19
+
20
+ _KEYS = [SIGNAL, BUCKET, WEIGHT]
21
+
22
+
23
+ # 所有曲线共用的视觉原点:最早一期的前一个工作日
24
+ def shared_origin(returns: pd.DataFrame) -> pd.Timestamp:
25
+ return pd.Timestamp(returns[DATE].min()) - pd.tseries.offsets.BDay(1)
26
+
27
+
28
+ # 净值走 (1+r) 累乘,对数曲线走 log1p 累加;每条线前置一个零点
29
+ def build_curves(
30
+ returns: pd.DataFrame,
31
+ clip_lower: float = -0.99,
32
+ origin: Optional[pd.Timestamp] = None,
33
+ ) -> pd.DataFrame:
34
+ frames = []
35
+ for keys, grp in returns.groupby(_KEYS, sort=False):
36
+ line = grp.dropna(subset=[RET]).sort_values(DATE)
37
+ if line.empty:
38
+ continue
39
+ rets = line[RET].astype(float).clip(lower=clip_lower).to_numpy()
40
+ body = pd.DataFrame({
41
+ DATE: line[DATE].to_numpy(),
42
+ RET: rets,
43
+ EQUITY: np.cumprod(1.0 + rets),
44
+ CUM_LOG_RET: np.log1p(rets).cumsum(),
45
+ })
46
+ start = origin if origin is not None else shared_origin(line)
47
+ head = pd.DataFrame({DATE: [start], RET: [0.0], EQUITY: [1.0], CUM_LOG_RET: [0.0]})
48
+ curve = pd.concat([head, body], ignore_index=True)
49
+ for name, value in zip(_KEYS, keys):
50
+ curve[name] = value
51
+ frames.append(curve)
52
+ if not frames:
53
+ return pd.DataFrame(columns=[DATE, *_KEYS, RET, EQUITY, CUM_LOG_RET])
54
+ out = pd.concat(frames, ignore_index=True)
55
+ return out[[DATE, *_KEYS, RET, EQUITY, CUM_LOG_RET]]
56
+
57
+
58
+ # 全样本单一常数缩放,使各序列波动率对齐基准;基准自身不缩放
59
+ def vol_rescale_to_reference(
60
+ returns: pd.DataFrame, reference: str, min_periods: int = 20
61
+ ) -> pd.DataFrame:
62
+ ref = returns[(returns[SIGNAL] == reference) & (returns[BUCKET] == REFERENCE_BUCKET)]
63
+ if ref.empty:
64
+ ref = returns[returns[SIGNAL] == reference]
65
+ if ref.empty:
66
+ raise ValueError(f"vol_scale.reference='{reference}' 在收益表中不存在")
67
+ targets = {w: _std(g[RET].to_numpy(), min_periods) for w, g in ref.groupby(WEIGHT)}
68
+
69
+ def rescale(grp: pd.DataFrame) -> pd.DataFrame:
70
+ if grp[SIGNAL].iloc[0] == reference:
71
+ return grp
72
+ target = targets.get(grp[WEIGHT].iloc[0], np.nan)
73
+ own = _std(grp[RET].to_numpy(), min_periods)
74
+ factor = target / own if np.isfinite(target) and np.isfinite(own) and own > 0 else 1.0
75
+ return grp.assign(**{RET: grp[RET] * factor})
76
+
77
+ scaled = [rescale(g) for _, g in returns.groupby(_KEYS, sort=False)]
78
+ return pd.concat(scaled, ignore_index=True)[returns.columns]
79
+
80
+
81
+ def _std(values: np.ndarray, min_periods: int) -> float:
82
+ finite = values[np.isfinite(values)]
83
+ if len(finite) < int(min_periods):
84
+ return float("nan")
85
+ return float(np.std(finite, ddof=1))
@@ -0,0 +1,72 @@
1
+ # 换手率与信息系数:只依赖 Engine 交出的成分表与对齐面板
2
+ from __future__ import annotations
3
+
4
+ from typing import Dict, List, Optional, Set
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from ..contracts import (
10
+ ALPHA,
11
+ ASSET,
12
+ BUCKET,
13
+ DATE,
14
+ FWD_RET,
15
+ IC,
16
+ N_NAMES,
17
+ SIGNAL,
18
+ TURNOVER,
19
+ )
20
+
21
+
22
+ # 相邻两期成分的 Jaccard 距离;多空桶取多头与空头的并集
23
+ def compute_turnover(
24
+ members: pd.DataFrame,
25
+ long_short_label: Optional[str] = None,
26
+ long_bucket: Optional[str] = None,
27
+ short_bucket: Optional[str] = None,
28
+ ) -> pd.DataFrame:
29
+ rows: List[Dict] = []
30
+ for signal, per_signal in members.groupby(SIGNAL, sort=True):
31
+ previous: Dict[str, Set[str]] = {}
32
+ for date, frame in per_signal.groupby(DATE, sort=True):
33
+ current = {
34
+ str(bucket): set(grp[ASSET])
35
+ for bucket, grp in frame.groupby(BUCKET, sort=False)
36
+ }
37
+ if long_short_label and long_bucket in current and short_bucket in current:
38
+ current[long_short_label] = current[long_bucket] | current[short_bucket]
39
+ for bucket, names in current.items():
40
+ prior = previous.get(bucket)
41
+ union = (names | prior) if prior is not None else None
42
+ rows.append({
43
+ SIGNAL: signal,
44
+ BUCKET: bucket,
45
+ DATE: date,
46
+ TURNOVER: 1.0 - len(names & prior) / len(union) if union else np.nan,
47
+ })
48
+ previous = current
49
+ return pd.DataFrame(rows, columns=[SIGNAL, BUCKET, DATE, TURNOVER])
50
+
51
+
52
+ # 每期截面 alpha 与实现收益的 Spearman 相关(秩的 Pearson)
53
+ def compute_ic(aligned: pd.DataFrame, min_names: int = 20) -> pd.DataFrame:
54
+ panel = aligned.dropna(subset=[ALPHA, FWD_RET]).copy()
55
+ if panel.empty:
56
+ return pd.DataFrame(columns=[SIGNAL, DATE, IC, N_NAMES])
57
+ keys = [SIGNAL, DATE]
58
+ x = panel.groupby(keys, sort=False)[ALPHA].rank()
59
+ y = panel.groupby(keys, sort=False)[FWD_RET].rank()
60
+ # 秩的 Pearson 相关,用各阶和一次聚合出来,避免逐组 apply
61
+ stats = panel.assign(_x=x, _y=y, _xx=x * x, _yy=y * y, _xy=x * y).groupby(keys, sort=True).agg(
62
+ n=("_x", "size"), sx=("_x", "sum"), sy=("_y", "sum"),
63
+ sxx=("_xx", "sum"), syy=("_yy", "sum"), sxy=("_xy", "sum"),
64
+ )
65
+ cov = stats["sxy"] - stats["sx"] * stats["sy"] / stats["n"]
66
+ var_x = stats["sxx"] - stats["sx"] ** 2 / stats["n"]
67
+ var_y = stats["syy"] - stats["sy"] ** 2 / stats["n"]
68
+ with np.errstate(invalid="ignore", divide="ignore"):
69
+ ic = cov / np.sqrt(var_x * var_y)
70
+ ic = ic.where(stats["n"] >= int(min_names))
71
+ out = pd.DataFrame({IC: ic, N_NAMES: stats["n"]}).reset_index()
72
+ return out[[SIGNAL, DATE, IC, N_NAMES]]
@@ -0,0 +1,100 @@
1
+ # 组合层指标:可注册、只吃一维简单收益序列
2
+ from __future__ import annotations
3
+
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+ from ..registry import Registry
10
+
11
+ METRICS: Registry = Registry("metric")
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class MetricContext:
16
+ periods_per_year: float
17
+ risk_free_rate: float = 0.0
18
+
19
+
20
+ class Metric(ABC):
21
+ name: str = ""
22
+
23
+ @abstractmethod
24
+ def compute(self, rets: np.ndarray, ctx: MetricContext) -> float:
25
+ ...
26
+
27
+
28
+ @METRICS.register()
29
+ class AnnualizedReturn(Metric):
30
+ name = "ann_ret"
31
+
32
+ def compute(self, rets: np.ndarray, ctx: MetricContext) -> float:
33
+ return float(rets.mean() * ctx.periods_per_year)
34
+
35
+
36
+ # 净值按简单收益累乘,再折回每年一期的复合增长率。与 ann_ret 是两套口径:
37
+ # ann_ret 把单期收益线性放大 P 倍,cagr 则让它们逐期滚动复利。月频面板上后者通常明显
38
+ # 更高——复利的凸性 (1+m)^P − 1 > m·P 压过了波动拖累。净值跌破 0 时复合增长率无定义。
39
+ @METRICS.register()
40
+ class CompoundAnnualGrowthRate(Metric):
41
+ name = "cagr"
42
+
43
+ def compute(self, rets: np.ndarray, ctx: MetricContext) -> float:
44
+ if len(rets) == 0:
45
+ return float("nan")
46
+ equity = float(np.cumprod(1.0 + rets)[-1])
47
+ if equity <= 0.0:
48
+ return float("nan")
49
+ return float(equity ** (ctx.periods_per_year / len(rets)) - 1.0)
50
+
51
+
52
+ @METRICS.register()
53
+ class AnnualizedVolatility(Metric):
54
+ name = "ann_vol"
55
+
56
+ def compute(self, rets: np.ndarray, ctx: MetricContext) -> float:
57
+ if len(rets) < 2:
58
+ return float("nan")
59
+ return float(rets.std(ddof=1) * np.sqrt(ctx.periods_per_year))
60
+
61
+
62
+ @METRICS.register()
63
+ class Sharpe(Metric):
64
+ name = "sharpe"
65
+
66
+ def compute(self, rets: np.ndarray, ctx: MetricContext) -> float:
67
+ vol = AnnualizedVolatility().compute(rets, ctx)
68
+ if not np.isfinite(vol) or vol <= 0:
69
+ return float("nan")
70
+ return float((AnnualizedReturn().compute(rets, ctx) - ctx.risk_free_rate) / vol)
71
+
72
+
73
+ # 净值按简单收益复利,回撤取全程最低点
74
+ @METRICS.register()
75
+ class MaxDrawdown(Metric):
76
+ name = "max_drawdown"
77
+
78
+ def compute(self, rets: np.ndarray, ctx: MetricContext) -> float:
79
+ equity = np.cumprod(1.0 + rets)
80
+ return float((equity / np.maximum.accumulate(equity) - 1.0).min())
81
+
82
+
83
+ @METRICS.register()
84
+ class TotalEquity(Metric):
85
+ name = "total_equity"
86
+
87
+ def compute(self, rets: np.ndarray, ctx: MetricContext) -> float:
88
+ return float(np.cumprod(1.0 + rets)[-1])
89
+
90
+
91
+ @METRICS.register()
92
+ class HitRate(Metric):
93
+ name = "hit_rate"
94
+
95
+ def compute(self, rets: np.ndarray, ctx: MetricContext) -> float:
96
+ return float((rets > 0).mean())
97
+
98
+
99
+ def build_metric(name: str) -> Metric:
100
+ return METRICS.get(name)()