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 +44 -0
- alpholio/analyzer/__init__.py +18 -0
- alpholio/analyzer/analyzer.py +135 -0
- alpholio/analyzer/curves.py +85 -0
- alpholio/analyzer/diagnostics.py +72 -0
- alpholio/analyzer/metrics.py +100 -0
- alpholio/api.py +518 -0
- alpholio/benchmark.py +109 -0
- alpholio/cli.py +38 -0
- alpholio/config_schema.py +439 -0
- alpholio/contracts.py +163 -0
- alpholio/engine/__init__.py +27 -0
- alpholio/engine/alignment.py +157 -0
- alpholio/engine/cross_section.py +84 -0
- alpholio/engine/engine.py +245 -0
- alpholio/engine/weighting.py +56 -0
- alpholio/frequency.py +74 -0
- alpholio/input/__init__.py +24 -0
- alpholio/input/processor.py +182 -0
- alpholio/input/sources.py +253 -0
- alpholio/io.py +145 -0
- alpholio/pipeline.py +43 -0
- alpholio/presets.py +64 -0
- alpholio/registry.py +43 -0
- alpholio/settings.py +44 -0
- alpholio/visualizer/__init__.py +16 -0
- alpholio/visualizer/charts.py +204 -0
- alpholio/visualizer/style.py +134 -0
- alpholio/visualizer/tables.py +63 -0
- alpholio/visualizer/visualizer.py +123 -0
- alpholio-0.4.0.dist-info/METADATA +173 -0
- alpholio-0.4.0.dist-info/RECORD +36 -0
- alpholio-0.4.0.dist-info/WHEEL +5 -0
- alpholio-0.4.0.dist-info/entry_points.txt +2 -0
- alpholio-0.4.0.dist-info/licenses/LICENSE +21 -0
- alpholio-0.4.0.dist-info/top_level.txt +1 -0
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)()
|