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

1"""Input validation for `BasanosEngine`. 

2 

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""" 

8 

9from __future__ import annotations 

10 

11import logging 

12 

13import polars as pl 

14 

15from ..exceptions import ( 

16 ColumnMismatchError, 

17 ExcessiveNullsError, 

18 MissingDateColumnError, 

19 MonotonicPricesError, 

20 NonPositivePricesError, 

21 ShapeMismatchError, 

22) 

23from ._config import BasanosConfig, CovarianceMode 

24 

25_logger = logging.getLogger(__name__) 

26 

27 

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") 

34 

35 

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) 

42 

43 

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()] 

47 

48 

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) 

55 

56 

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) 

66 

67 

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) 

76 

77 

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 ) 

92 

93 

94def _validate_inputs(prices: pl.DataFrame, mu: pl.DataFrame, cfg: BasanosConfig) -> None: 

95 """Validate ``prices``, ``mu``, and ``cfg`` for use with `BasanosEngine`. 

96 

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. 

102 

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. 

107 

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. 

116 

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)