Coverage for src/basanos/math/_engine_validation.py: 100%
52 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"""Input validation for `BasanosEngine`.
3Extracted from ``optimizer.py`` so the engine facade stays focused on the
4core position-solving logic. Every name defined here is re-exported from
5``optimizer`` so existing callers (and tests that import ``_validate_inputs`` /
6``_validate_null_fraction`` from ``basanos.math.optimizer``) are unaffected.
7"""
9from __future__ import annotations
11import logging
13import polars as pl
15from ..exceptions import (
16 ColumnMismatchError,
17 ExcessiveNullsError,
18 MissingDateColumnError,
19 MonotonicPricesError,
20 NonPositivePricesError,
21 ShapeMismatchError,
22)
23from ._config import BasanosConfig, CovarianceMode
25_logger = logging.getLogger(__name__)
28def _validate_required_date_columns(prices: pl.DataFrame, mu: pl.DataFrame) -> None:
29 """Ensure both input frames expose the required ``date`` column."""
30 if "date" not in prices.columns:
31 raise MissingDateColumnError("prices")
32 if "date" not in mu.columns:
33 raise MissingDateColumnError("mu")
36def _validate_shape_and_column_sets(prices: pl.DataFrame, mu: pl.DataFrame) -> None:
37 """Ensure prices and signals are shape- and schema-compatible."""
38 if prices.shape != mu.shape:
39 raise ShapeMismatchError(prices.shape, mu.shape)
40 if not set(prices.columns) == set(mu.columns):
41 raise ColumnMismatchError(prices.columns, mu.columns)
44def _numeric_assets(prices: pl.DataFrame) -> list[str]:
45 """Return numeric asset columns, excluding the ``date`` column."""
46 return [c for c in prices.columns if c != "date" and prices[c].dtype.is_numeric()]
49def _validate_positive_prices(prices: pl.DataFrame, assets: list[str]) -> None:
50 """Ensure all finite/non-null prices are strictly positive."""
51 for asset in assets:
52 col = prices[asset].drop_nulls()
53 if col.len() > 0 and (col <= 0).any():
54 raise NonPositivePricesError(asset)
57def _validate_null_fraction(prices: pl.DataFrame, assets: list[str], max_nan_fraction: float) -> None:
58 """Reject asset columns whose null fraction exceeds configuration bounds."""
59 n_rows = prices.height
60 if n_rows == 0:
61 return
62 for asset in assets:
63 nan_frac = prices[asset].null_count() / n_rows
64 if nan_frac > max_nan_fraction:
65 raise ExcessiveNullsError(asset, nan_frac, max_nan_fraction)
68def _validate_non_monotonic_prices(prices: pl.DataFrame, assets: list[str]) -> None:
69 """Reject monotonic asset series that indicate malformed synthetic data."""
70 for asset in assets:
71 col = prices[asset].drop_nulls()
72 if col.len() > 2:
73 diffs = col.diff().drop_nulls()
74 if (diffs >= 0).all() or (diffs <= 0).all():
75 raise MonotonicPricesError(asset)
78def _warn_short_sliding_window_data(prices: pl.DataFrame, cfg: BasanosConfig) -> None:
79 """Emit a warning when data is too short relative to the configured SW window."""
80 if cfg.covariance_mode == CovarianceMode.sliding_window and cfg.window is not None:
81 n_rows = prices.height
82 w: int = cfg.window
83 if n_rows < 2 * w:
84 _logger.warning(
85 "Dataset length (%d rows) is less than 2 * window (%d). "
86 "The first %d timestamps will yield zero positions during warm-up; "
87 "consider using a longer history or reducing 'window'.",
88 n_rows,
89 2 * w,
90 w - 1,
91 )
94def _validate_inputs(prices: pl.DataFrame, mu: pl.DataFrame, cfg: BasanosConfig) -> None:
95 """Validate ``prices``, ``mu``, and ``cfg`` for use with `BasanosEngine`.
97 Checks that both DataFrames contain a ``'date'`` column, share identical
98 shapes and column sets, contain no non-positive prices, no excessive NaN
99 fractions, and no monotonically non-varying price series. Also emits a
100 warning when the dataset is too short relative to a configured
101 sliding-window size.
103 Args:
104 prices: DataFrame of price levels per asset over time.
105 mu: DataFrame of expected-return signals aligned with ``prices``.
106 cfg: Engine configuration instance.
108 Raises:
109 MissingDateColumnError: If ``'date'`` is absent from either frame.
110 ShapeMismatchError: If ``prices`` and ``mu`` have different shapes.
111 ColumnMismatchError: If the column sets of the two frames differ.
112 NonPositivePricesError: If any asset contains a non-positive price.
113 ExcessiveNullsError: If any asset column exceeds ``cfg.max_nan_fraction``.
114 MonotonicPricesError: If any asset price series is monotonically
115 non-decreasing or non-increasing.
117 Warns:
118 UserWarning (via logging): If ``cfg.covariance`` is a
119 `SlidingWindowConfig` and
120 ``len(prices) < 2 * cfg.covariance.window``, a warning is emitted
121 via the module logger rather than an exception. This is a
122 deliberate soft boundary — callers may intentionally supply data
123 shorter than the full warm-up period. During warm-up the first
124 ``window - 1`` timestamps will yield zero positions.
125 """
126 _validate_required_date_columns(prices, mu)
127 _validate_shape_and_column_sets(prices, mu)
128 assets = _numeric_assets(prices)
129 _validate_positive_prices(prices, assets)
130 _validate_null_fraction(prices, assets, cfg.max_nan_fraction)
131 _validate_non_monotonic_prices(prices, assets)
132 _warn_short_sliding_window_data(prices, cfg)