Coverage for src/basanos/math/_stream_math.py: 100%
35 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"""Pure numerical helpers for the incremental (streaming) optimiser.
3These functions carry no stream state — they are factored out of
4:mod:`basanos.math._stream` so the EWMA recurrences and the per-step input
5validation can be read, reused, and tested in isolation.
6"""
8from __future__ import annotations
10import numpy as np
11from scipy.signal import lfilter
14def _ewm_std_from_state(
15 s_x: np.ndarray,
16 s_x2: np.ndarray,
17 s_w: np.ndarray,
18 s_w2: np.ndarray,
19 count: np.ndarray,
20 min_samples: int,
21) -> np.ndarray:
22 r"""Compute the unbiased EWMA standard deviation from running accumulators.
24 Implements the same Bessel-corrected formula used by
25 ``polars.Expr.ewm_std(adjust=True)``::
27 var_biased = s_x2/s_w - (s_x/s_w)^2
28 correction = s_w^2 / (s_w^2 - s_w2) # Bessel correction
29 var_unbiased = var_biased * correction
30 std = sqrt(max(0, var_unbiased))
32 where ``s_w2 = sum(wi^2)`` is the sum of squared EWM weights.
34 Parameters
35 ----------
36 s_x, s_x2, s_w, s_w2:
37 Running accumulators, each of shape ``(N,)``.
38 count:
39 Integer count of finite observations per asset, shape ``(N,)``.
40 min_samples:
41 Minimum number of finite observations required before returning a
42 non-NaN value.
44 Returns:
45 -------
46 np.ndarray of shape ``(N,)`` with per-asset standard deviations.
47 NaN is returned for assets where ``count < min_samples``.
48 """
49 n = len(s_x)
50 result = np.full(n, np.nan, dtype=float)
51 ok = count >= min_samples
52 if not ok.any():
53 return result
55 with np.errstate(divide="ignore", invalid="ignore"):
56 mean = np.where(s_w > 0, s_x / s_w, 0.0)
57 mean_sq = np.where(s_w > 0, s_x2 / s_w, 0.0)
58 var_biased = np.maximum(mean_sq - mean**2, 0.0)
59 denom_corr = s_w**2 - s_w2
60 # denom_corr > 0 iff count >= 2; equals 0 when count == 1
61 var_unbiased = np.where(denom_corr > 0, var_biased * s_w**2 / denom_corr, 0.0)
62 std = np.sqrt(var_unbiased)
64 return np.where(ok, std, np.nan)
67def _ewm_vol_accumulators_from_batch(
68 returns: np.ndarray,
69 beta: float,
70 beta_sq: float,
71) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
72 r"""Compute final EWMA volatility accumulators from a batch of returns.
74 Implements the same IIR recurrence as ``BasanosStream.step`` but
75 vectorised over *T* timesteps using ``scipy.signal.lfilter``. The five
76 returned arrays are identical to the accumulators that would result from
77 feeding each row of *returns* through the scalar step-by-step recurrence::
79 s_x[t] = beta * s_x[t-1] + (x[t] if finite else 0)
80 s_x2[t] = beta * s_x2[t-1] + (x[t]^2 if finite else 0)
81 s_w[t] = beta * s_w[t-1] + (1 if finite else 0)
82 s_w2[t] = beta^2 * s_w2[t-1] + (1 if finite else 0)
84 Parameters
85 ----------
87 Returns:
88 Float array of shape ``(T, N)``. NaN entries are treated as missing
89 observations — they contribute nothing to the numerator sums and do
90 not increment the weight accumulators.
91 beta:
92 EWM decay factor for ``s_x``, ``s_x2``, and ``s_w``
93 (``beta = (com) / (1 + com)`` for ``com = cfg.vola - 1``).
94 beta_sq:
95 Squared decay factor used for ``s_w2``. Must equal ``beta ** 2``.
97 Returns:
98 -------
99 s_x, s_x2, s_w, s_w2 : np.ndarray of shape ``(N,)``
100 Final EWMA running accumulators after processing all *T* rows.
101 count : np.ndarray of shape ``(N,)`` dtype int
102 Number of finite observations per asset.
104 Notes:
105 -----
106 This function is the shared implementation used by
107 ``BasanosStream.from_warmup`` for both the log-return (``vola_*``)
108 and pct-return (``pct_*``) accumulators. Keeping a single implementation
109 here guarantees that the batch and incremental paths stay in sync when the
110 recurrence definition changes.
111 """
112 fin = np.isfinite(returns).astype(np.float64) # (T, N)
113 x_z = np.where(fin.astype(bool), returns, 0.0) # (T, N)
114 filt_a = np.array([1.0, -beta])
115 filt_a2 = np.array([1.0, -beta_sq])
117 s_x: np.ndarray = lfilter([1.0], filt_a, x_z, axis=0)[-1]
118 s_x2: np.ndarray = lfilter([1.0], filt_a, x_z**2, axis=0)[-1]
119 s_w: np.ndarray = lfilter([1.0], filt_a, fin, axis=0)[-1]
120 s_w2: np.ndarray = lfilter([1.0], filt_a2, fin, axis=0)[-1]
121 count: np.ndarray = fin.sum(axis=0).astype(int)
123 return s_x, s_x2, s_w, s_w2, count
126def _resolve_step_vector(
127 values: np.ndarray | dict[str, float],
128 assets: list[str],
129 n_assets: int,
130 arg_name: str,
131) -> np.ndarray:
132 """Resolve one step input to a validated ``(N,)`` float vector.
134 Args:
135 values: Raw input provided to ``step`` as either dict or array-like.
136 assets: Ordered asset names used when ``values`` is a mapping.
137 n_assets: Expected vector length.
138 arg_name: Argument label used in shape-mismatch errors.
140 Returns:
141 A float64 numpy vector of shape ``(n_assets,)``.
143 Raises:
144 ValueError: If the resolved vector does not match ``(n_assets,)``.
145 """
146 if isinstance(values, dict):
147 vector = np.array([float(values[a]) for a in assets], dtype=float)
148 else:
149 vector = np.asarray(values, dtype=float).ravel()
150 if vector.shape != (n_assets,):
151 raise ValueError(f"{arg_name} must have shape ({n_assets},); got {vector.shape}") # noqa: TRY003
152 return vector