Coverage for src/basanos/math/_engine_ic.py: 100%
61 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-04 07:53 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-08-04 07:53 +0000
1"""Signal-evaluation mixin for BasanosEngine.
3Provides information-coefficient (IC) metrics as a reusable mixin so that
4``optimizer.py`` stays focused on the core position-solving logic.
6Classes in this module are **private implementation details**. The public API
7is `BasanosEngine`, which inherits from
8`_SignalEvaluatorMixin`.
9"""
11from __future__ import annotations
13from typing import TYPE_CHECKING
15import numpy as np
16import polars as pl
17from scipy.stats import spearmanr
19if TYPE_CHECKING:
20 from ._engine_protocol import _EngineProtocol
23def _correlate(signal: np.ndarray, fwd_ret: np.ndarray, *, use_rank: bool) -> float:
24 """Correlate two finite vectors, returning ``nan`` for degenerate input.
26 Both `np.corrcoef` and `scipy.stats.spearmanr` emit warnings (numpy
27 ``invalid value encountered in divide``; scipy ``ConstantInputWarning``)
28 and yield ``nan`` when either input has zero variance (a constant column).
29 That degenerate case is expected — an IC is undefined when a signal or the
30 forward returns are flat — so it is short-circuited to ``nan`` here before
31 the correlation is attempted, keeping the test log and any downstream
32 caller free of the warning.
34 Args:
35 signal: Finite signal values for the assets valid at this timestamp.
36 fwd_ret: Matching finite forward returns.
37 use_rank: When ``True`` use Spearman rank correlation; otherwise Pearson.
39 Returns:
40 float: The correlation, or ``nan`` when either input is constant.
41 """
42 if signal.std() == 0.0 or fwd_ret.std() == 0.0:
43 return float("nan")
44 if use_rank:
45 corr, _ = spearmanr(signal, fwd_ret)
46 return float(corr)
47 return float(np.corrcoef(signal, fwd_ret)[0, 1])
50class _SignalEvaluatorMixin:
51 """Mixin providing cross-sectional information-coefficient (IC) metrics.
53 The consuming class must satisfy `_EngineProtocol`,
54 i.e. it must expose:
56 * ``assets`` — list of asset column names
57 * ``prices`` — Polars DataFrame with a ``'date'`` column
58 * ``mu`` — Polars DataFrame of expected-return signals
59 """
61 def _ic_series(self: _EngineProtocol, use_rank: bool, h: int = 1) -> pl.DataFrame:
62 """Compute the cross-sectional IC time series.
64 For each timestamp *t* (from 0 to T-1-h), correlates the signal vector
65 ``mu[t, :]`` with the *h*-period forward return vector
66 ``prices[t+h, :] / prices[t, :] - 1`` across all assets where both
67 quantities are finite. When fewer than two valid asset pairs are
68 available, the IC value is set to ``NaN``.
70 Args:
71 use_rank: When ``True`` the Spearman rank correlation is used
72 (Rank IC); when ``False`` the Pearson correlation is used (IC).
73 h: Forward-return horizon in periods. ``h=1`` (default) gives the
74 classic one-period IC; ``h=5`` evaluates signal quality against
75 five-period returns. Must be >= 1.
77 Returns:
78 pl.DataFrame: Two-column frame with ``date`` (signal date) and
79 either ``ic`` or ``rank_ic``.
81 Raises:
82 ValueError: If *h* < 1.
83 """
84 if h < 1:
85 msg = f"h must be >= 1, got {h}"
86 raise ValueError(msg)
88 assets = self.assets
89 prices_np = self.prices.select(assets).to_numpy().astype(float)
90 mu_np = self.mu.select(assets).to_numpy().astype(float)
91 dates = self.prices["date"].to_list()
93 col_name = "rank_ic" if use_rank else "ic"
94 ic_values: list[float] = []
95 ic_dates = []
97 for t in range(len(dates) - h):
98 fwd_ret = prices_np[t + h] / prices_np[t] - 1.0
99 signal = mu_np[t]
101 # Both signal and forward return must be finite
102 mask = np.isfinite(signal) & np.isfinite(fwd_ret)
103 n_valid = int(mask.sum())
105 ic_values.append(
106 _correlate(signal[mask], fwd_ret[mask], use_rank=use_rank) if n_valid >= 2 else float("nan")
107 )
108 ic_dates.append(dates[t])
110 return pl.DataFrame({"date": ic_dates, col_name: pl.Series(ic_values, dtype=pl.Float64)})
112 def ic(self: _EngineProtocol, h: int = 1) -> pl.DataFrame:
113 """Cross-sectional Pearson Information Coefficient (IC) time series.
115 For each timestamp *t*, computes the Pearson correlation between the
116 signal ``mu[t, :]`` and the *h*-period forward return
117 ``prices[t+h, :] / prices[t, :] - 1`` across all assets where both
118 quantities are finite.
120 An IC value close to +1 means the signal ranked assets in the same
121 order as forward returns; close to -1 means the opposite; near 0 means
122 no predictive relationship.
124 Args:
125 h: Forward-return horizon in periods (default 1).
127 Returns:
128 pl.DataFrame: Frame with columns ``['date', 'ic']``. ``date`` is
129 the timestamp at which the signal was observed. ``ic`` is a
130 ``Float64`` series (``NaN`` when fewer than 2 valid asset pairs
131 are available for a given timestamp).
133 See Also:
134 `rank_ic` — Spearman variant, more robust to outliers.
135 `ic_mean`, `ic_std`, `icir` — summary
136 statistics.
137 """
138 return self._ic_series(use_rank=False, h=h)
140 def rank_ic(self: _EngineProtocol, h: int = 1) -> pl.DataFrame:
141 """Cross-sectional Spearman Rank Information Coefficient time series.
143 Identical to `ic` but uses the Spearman rank correlation
144 instead of the Pearson correlation, making it more robust to fat-tailed
145 return distributions and outliers.
147 Args:
148 h: Forward-return horizon in periods (default 1).
150 Returns:
151 pl.DataFrame: Frame with columns ``['date', 'rank_ic']``.
152 ``rank_ic`` is a ``Float64`` series.
154 See Also:
155 `ic` — Pearson variant.
156 `rank_ic_mean`, `rank_ic_std` — summary
157 statistics.
158 """
159 return self._ic_series(use_rank=True, h=h)
161 def ic_mean(self: _EngineProtocol, h: int = 1) -> float:
162 """Mean of the IC time series, ignoring NaN values.
164 Args:
165 h: Forward-return horizon in periods (default 1).
167 Returns:
168 float: Arithmetic mean of all finite IC values, or ``NaN`` if
169 no finite values exist.
170 """
171 arr = self._ic_series(use_rank=False, h=h)["ic"].drop_nulls().to_numpy()
172 finite = arr[np.isfinite(arr)]
173 return float(np.mean(finite)) if len(finite) > 0 else float("nan")
175 def ic_std(self: _EngineProtocol, h: int = 1) -> float:
176 """Standard deviation of the IC time series, ignoring NaN values.
178 Uses ``ddof=1`` (sample standard deviation).
180 Args:
181 h: Forward-return horizon in periods (default 1).
183 Returns:
184 float: Sample standard deviation of all finite IC values, or
185 ``NaN`` if fewer than 2 finite values exist.
186 """
187 arr = self._ic_series(use_rank=False, h=h)["ic"].drop_nulls().to_numpy()
188 finite = arr[np.isfinite(arr)]
189 return float(np.std(finite, ddof=1)) if len(finite) > 1 else float("nan")
191 def icir(self: _EngineProtocol, h: int = 1) -> float:
192 """Information Coefficient Information Ratio (ICIR).
194 Defined as ``IC mean / IC std``. A higher absolute ICIR indicates a
195 more consistent signal: the mean IC is large relative to its
196 variability.
198 Args:
199 h: Forward-return horizon in periods (default 1).
201 Returns:
202 float: ``ic_mean / ic_std``, or ``NaN`` when ``ic_std`` is zero
203 or non-finite.
204 """
205 ic_df = self._ic_series(use_rank=False, h=h)
206 arr = ic_df["ic"].drop_nulls().to_numpy()
207 finite = arr[np.isfinite(arr)]
208 mean = float(np.mean(finite)) if len(finite) > 0 else float("nan")
209 std = float(np.std(finite, ddof=1)) if len(finite) > 1 else float("nan")
210 if not np.isfinite(std) or std == 0.0:
211 return float("nan")
212 return float(mean / std)
214 def rank_ic_mean(self: _EngineProtocol, h: int = 1) -> float:
215 """Mean of the Rank IC time series, ignoring NaN values.
217 Args:
218 h: Forward-return horizon in periods (default 1).
220 Returns:
221 float: Arithmetic mean of all finite Rank IC values, or ``NaN``
222 if no finite values exist.
223 """
224 arr = self._ic_series(use_rank=True, h=h)["rank_ic"].drop_nulls().to_numpy()
225 finite = arr[np.isfinite(arr)]
226 return float(np.mean(finite)) if len(finite) > 0 else float("nan")
228 def rank_ic_std(self: _EngineProtocol, h: int = 1) -> float:
229 """Standard deviation of the Rank IC time series, ignoring NaN values.
231 Uses ``ddof=1`` (sample standard deviation).
233 Args:
234 h: Forward-return horizon in periods (default 1).
236 Returns:
237 float: Sample standard deviation of all finite Rank IC values, or
238 ``NaN`` if fewer than 2 finite values exist.
239 """
240 arr = self._ic_series(use_rank=True, h=h)["rank_ic"].drop_nulls().to_numpy()
241 finite = arr[np.isfinite(arr)]
242 return float(np.std(finite, ddof=1)) if len(finite) > 1 else float("nan")