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