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

1"""Input validation for `BasanosEngine`. 

2 

3Extracted from ``optimizer.py`` so the engine facade stays focused on the 

4core position-solving logic. 

5""" 

6 

7from __future__ import annotations 

8 

9import logging 

10 

11import polars as pl 

12 

13from ..exceptions import ( 

14 ColumnMismatchError, 

15 ExcessiveNullsError, 

16 MissingDateColumnError, 

17 MonotonicPricesError, 

18 NonPositivePricesError, 

19 ShapeMismatchError, 

20) 

21from ._config import BasanosConfig, CovarianceMode 

22 

23_logger = logging.getLogger(__name__) 

24 

25 

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

32 

33 

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) 

40 

41 

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

45 

46 

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) 

53 

54 

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) 

64 

65 

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) 

74 

75 

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 ) 

90 

91 

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

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

94 

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. 

100 

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. 

105 

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. 

114 

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)