From a7534a64716f4b9f808d1de5e6cc36574661c010 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Tue, 3 Mar 2026 07:31:48 -0500 Subject: [PATCH 1/3] refactor: harden API, fix correctness issues, clean up for beta readiness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking changes (alpha, clean breaks): - Rename QuantLabTAError → ML4TEngineerError, remove all deprecated aliases - Remove COMMON_PARAM_DEFAULTS silent parameter fallback from compute_features - Delete selection/systematic.py (moved to ml4t-diagnostic) - Remove ml4t-style dependency Correctness fixes: - Fix GARCH forecast lookahead bias (was using future data for in-sample) - Fix trailing_stop=True with column-name barriers (ATR barriers) - Fix scaler column order preservation in StandardScaler/RobustScaler - Fix validate_ohlcv_schema dtype check (use is_numeric() not hardcoded list) - Fix TII value_range to (-100, 100) (indicator goes negative for downtrends) - Fix experiment config YAML tuple serialization roundtrip API improvements: - Add NaN validation at all labeling entry points - Add validate_ohlcv_schema guard in compute_features() - Add group_col parameter to trend_scanning_labels() for panel data - Add O(N*L) performance warning for atr_triple_barrier_labels - Add parameters= defaults to sma/ema/wma/cyclical_encode/time_decay_weights - Add BaseScaler.clone() method Cleanup (-2123 lines net): - Remove mypy: disable-error-code comments from ~50 feature files - Delete unused _calculate_drawdowns_nb numba function - Delete validate_numeric_column no-op - Remove deprecated plot_feature_analysis_summary stub - Remove coverage omissions for bars/microstructure Tests (+920 lines): - Add test_catalog.py (31 tests for FeatureCatalog) - Add test_experiment_config.py (20 tests for ExperimentConfig) - Add test_integration_pipeline.py (19 tests for end-to-end pipeline) - Update existing tests for NaN validation behavior changes --- pyproject.toml | 7 - src/ml4t/engineer/AGENT.md | 6 +- src/ml4t/engineer/api.py | 36 +- src/ml4t/engineer/bars/run.py | 1 - src/ml4t/engineer/bars/vectorized.py | 1 - src/ml4t/engineer/config/base.py | 1 - src/ml4t/engineer/config/experiment.py | 13 +- src/ml4t/engineer/config/feature_config.py | 1 - src/ml4t/engineer/config/labeling.py | 1 - .../engineer/config/preprocessing_config.py | 4 +- src/ml4t/engineer/core/__init__.py | 14 +- src/ml4t/engineer/core/decorators.py | 1 - src/ml4t/engineer/core/exceptions.py | 71 +- src/ml4t/engineer/core/registry.py | 2 +- src/ml4t/engineer/core/schemas.py | 9 +- src/ml4t/engineer/core/validation.py | 18 - src/ml4t/engineer/dataset.py | 15 +- src/ml4t/engineer/features/fdiff.py | 1 - src/ml4t/engineer/features/math/max.py | 1 - src/ml4t/engineer/features/math/min.py | 1 - src/ml4t/engineer/features/math/sum.py | 1 - .../engineer/features/ml/cyclical_encode.py | 2 +- .../features/ml/directional_targets.py | 1 - .../features/ml/interaction_features.py | 1 - .../engineer/features/ml/rolling_entropy.py | 1 - .../features/ml/time_decay_weights.py | 3 +- src/ml4t/engineer/features/momentum/adx.py | 1 - src/ml4t/engineer/features/momentum/adxr.py | 1 - src/ml4t/engineer/features/momentum/apo.py | 1 - src/ml4t/engineer/features/momentum/aroon.py | 1 - src/ml4t/engineer/features/momentum/bop.py | 1 - src/ml4t/engineer/features/momentum/cci.py | 1 - src/ml4t/engineer/features/momentum/cmo.py | 1 - .../engineer/features/momentum/directional.py | 1 - src/ml4t/engineer/features/momentum/imi.py | 1 - src/ml4t/engineer/features/momentum/macd.py | 1 - .../engineer/features/momentum/macdfix.py | 1 - src/ml4t/engineer/features/momentum/mfi.py | 1 - .../engineer/features/momentum/minus_dm.py | 1 - .../engineer/features/momentum/plus_dm.py | 1 - src/ml4t/engineer/features/momentum/ppo.py | 1 - src/ml4t/engineer/features/momentum/roc.py | 1 - src/ml4t/engineer/features/momentum/rocp.py | 1 - src/ml4t/engineer/features/momentum/rocr.py | 1 - .../engineer/features/momentum/rocr100.py | 1 - src/ml4t/engineer/features/momentum/rsi.py | 1 - src/ml4t/engineer/features/momentum/sar.py | 1 - .../engineer/features/momentum/stochastic.py | 1 - src/ml4t/engineer/features/momentum/stochf.py | 1 - src/ml4t/engineer/features/momentum/trix.py | 1 - src/ml4t/engineer/features/momentum/ultosc.py | 1 - src/ml4t/engineer/features/momentum/willr.py | 1 - .../features/price_transform/avgprice.py | 1 - .../features/price_transform/medprice.py | 1 - .../features/price_transform/midprice.py | 1 - .../features/price_transform/typprice.py | 1 - .../features/price_transform/wclprice.py | 1 - src/ml4t/engineer/features/regime.py | 5 +- src/ml4t/engineer/features/risk.py | 58 -- .../engineer/features/statistics/avgdev.py | 1 - .../engineer/features/statistics/linearreg.py | 1 - .../features/statistics/linearreg_angle.py | 1 - .../statistics/linearreg_intercept.py | 1 - .../features/statistics/linearreg_slope.py | 1 - .../engineer/features/statistics/stddev.py | 1 - .../features/statistics/structural_break.py | 1 - src/ml4t/engineer/features/statistics/tsf.py | 1 - src/ml4t/engineer/features/statistics/var.py | 1 - src/ml4t/engineer/features/trend/dema.py | 1 - src/ml4t/engineer/features/trend/donchian.py | 1 - src/ml4t/engineer/features/trend/ema.py | 4 +- src/ml4t/engineer/features/trend/kama.py | 1 - src/ml4t/engineer/features/trend/midpoint.py | 1 - src/ml4t/engineer/features/trend/sma.py | 4 +- src/ml4t/engineer/features/trend/t3.py | 1 - src/ml4t/engineer/features/trend/tema.py | 1 - src/ml4t/engineer/features/trend/trima.py | 1 - src/ml4t/engineer/features/trend/wma.py | 4 +- src/ml4t/engineer/features/volatility/atr.py | 1 - .../features/volatility/garch_forecast.py | 15 +- src/ml4t/engineer/features/volatility/natr.py | 1 - .../engineer/features/volatility/trange.py | 1 - src/ml4t/engineer/features/volume/ad.py | 1 - src/ml4t/engineer/features/volume/adosc.py | 1 - src/ml4t/engineer/features/volume/obv.py | 1 - src/ml4t/engineer/labeling/__init__.py | 1 - src/ml4t/engineer/labeling/atr_barriers.py | 27 +- src/ml4t/engineer/labeling/calendar.py | 2 +- src/ml4t/engineer/labeling/horizon_labels.py | 169 ++-- src/ml4t/engineer/labeling/numba_ops.py | 15 +- src/ml4t/engineer/labeling/triple_barrier.py | 20 +- src/ml4t/engineer/labeling/uniqueness.py | 1 - src/ml4t/engineer/labeling/utils.py | 21 + src/ml4t/engineer/logging/config.py | 1 - src/ml4t/engineer/logging/core.py | 1 - src/ml4t/engineer/preprocessing.py | 130 +-- .../relationships/plot_correlation.py | 1 - src/ml4t/engineer/selection/__init__.py | 51 +- src/ml4t/engineer/selection/systematic.py | 736 ---------------- src/ml4t/engineer/store/offline.py | 1 - src/ml4t/engineer/utils/dependencies.py | 1 - src/ml4t/engineer/visualization/__init__.py | 30 +- src/ml4t/engineer/visualization/summary.py | 51 +- tests/labeling/test_labeling_coverage.py | 11 +- tests/selection/__init__.py | 1 - tests/selection/test_systematic.py | 786 ------------------ tests/test_api.py | 33 +- tests/test_catalog.py | 256 ++++++ tests/test_experiment_config.py | 234 ++++++ tests/test_integration_pipeline.py | 430 ++++++++++ tests/test_labeling.py | 10 +- tests/test_labeling_calendar.py | 23 +- tests/visualization/test_summary.py | 30 +- 113 files changed, 1306 insertions(+), 2123 deletions(-) delete mode 100644 src/ml4t/engineer/selection/systematic.py delete mode 100644 tests/selection/__init__.py delete mode 100644 tests/selection/test_systematic.py create mode 100644 tests/test_catalog.py create mode 100644 tests/test_experiment_config.py create mode 100644 tests/test_integration_pipeline.py diff --git a/pyproject.toml b/pyproject.toml index 557e7e7..db22366 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -295,13 +295,6 @@ source = ["src/ml4t/engineer"] omit = [ "*/tests/*", "*/__init__.py", - "*/bars/base.py", - "*/bars/imbalance.py", - "*/bars/run.py", - "*/bars/vectorized.py", - "*/labeling/barriers.py", - "*/labeling/core.py", - "*/features/microstructure/*", ] [tool.coverage.report] diff --git a/src/ml4t/engineer/AGENT.md b/src/ml4t/engineer/AGENT.md index 008e2d1..ad638f1 100644 --- a/src/ml4t/engineer/AGENT.md +++ b/src/ml4t/engineer/AGENT.md @@ -45,11 +45,9 @@ Package-level navigation for feature engineering library. | `preprocessing_config.py` | Scaler config | `PreprocessingConfig`, `ScalerConfig` | | `base.py` | Base config classes | `BaseConfig` | -## selection/ - Feature Selection +## selection/ - Deprecated (Moved to ml4t-diagnostic) -| File | Purpose | -|------|---------| -| `systematic.py` | Correlation-based, variance threshold, mutual info | +FeatureSelector has moved to `ml4t.diagnostic.selection`. ## store/ - Storage diff --git a/src/ml4t/engineer/api.py b/src/ml4t/engineer/api.py index dd53866..184a22b 100644 --- a/src/ml4t/engineer/api.py +++ b/src/ml4t/engineer/api.py @@ -78,21 +78,6 @@ } ) -# Common default values for required parameters missing from metadata -# Used as fallback when feature registration is incomplete -COMMON_PARAM_DEFAULTS: dict[str, Any] = { - "period": 14, - "window": 20, - "lookback": 20, - "lag": 1, - "lags": [1], - "n": 5, - "bins": 10, - "features": ["close"], - "windows": [5, 10, 20], -} - - def compute_features( data: pl.DataFrame | pl.LazyFrame, features: list[str] | list[dict[str, Any]] | Path | str, @@ -175,6 +160,11 @@ def compute_features( - Circular dependencies are detected and raise ValueError - Parameters in config override default parameters from registry """ + from ml4t.engineer.core.schemas import validate_ohlcv_schema + + # Validate input schema (flexible: no asset_id required, flexible time column) + validate_ohlcv_schema(data, require_asset_id=False, allow_flexible_time=True) + # Parse input to standardized format feature_specs = _parse_feature_input(features) @@ -403,16 +393,12 @@ def _execute_feature( pass else: # Required parameter with no default and not in COLUMN_ARG_MAP - # This indicates incomplete metadata - try common defaults - if param_name in COMMON_PARAM_DEFAULTS: - keyword_params[param_name] = COMMON_PARAM_DEFAULTS[param_name] - else: - # Cannot proceed - need user to provide this parameter explicitly - raise ValueError( - f"Feature '{feature_name}' requires parameter '{param_name}' but it's not " - f"provided. Call with explicit parameters: " - f'compute_features(df, [{{"name": "{feature_name}", "{param_name}": value}}])' - ) + raise ValueError( + f"Feature '{feature_name}' requires parameter '{param_name}' but it's not " + f"provided in metadata.parameters or call params. " + f'Use: compute_features(df, [{{"name": "{feature_name}", ' + f'"params": {{"{param_name}": value}}}}])' + ) # Call the feature function try: diff --git a/src/ml4t/engineer/bars/run.py b/src/ml4t/engineer/bars/run.py index df93bb2..f08fe7f 100644 --- a/src/ml4t/engineer/bars/run.py +++ b/src/ml4t/engineer/bars/run.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="misc,operator,assignment,arg-type" """Run bar sampler implementations. AFML-compliant run bars per López de Prado Chapter 2.3. diff --git a/src/ml4t/engineer/bars/vectorized.py b/src/ml4t/engineer/bars/vectorized.py index f07bb00..4abd405 100644 --- a/src/ml4t/engineer/bars/vectorized.py +++ b/src/ml4t/engineer/bars/vectorized.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="misc,operator,assignment,arg-type" """ Vectorized bar samplers using Polars for high performance. diff --git a/src/ml4t/engineer/config/base.py b/src/ml4t/engineer/config/base.py index 4634191..52a55bf 100644 --- a/src/ml4t/engineer/config/base.py +++ b/src/ml4t/engineer/config/base.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="misc,no-any-return,type-arg" # ruff: noqa: E721 """Base configuration classes and shared utilities. diff --git a/src/ml4t/engineer/config/experiment.py b/src/ml4t/engineer/config/experiment.py index 0dc6d48..a3ef0f8 100644 --- a/src/ml4t/engineer/config/experiment.py +++ b/src/ml4t/engineer/config/experiment.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="misc,no-any-return" """Experiment configuration loading utilities. This module provides helpers for loading complete experiment configurations @@ -209,10 +208,22 @@ def save_experiment_config( exclude_none=True, ) + # Convert tuples to lists for YAML safe_load compatibility + output = _tuples_to_lists(output) + with open(path, "w") as f: yaml.dump(output, f, default_flow_style=False, sort_keys=False) +def _tuples_to_lists(obj: Any) -> Any: + """Recursively convert tuples to lists for YAML-safe serialization.""" + if isinstance(obj, dict): + return {k: _tuples_to_lists(v) for k, v in obj.items()} + elif isinstance(obj, tuple | list): + return [_tuples_to_lists(item) for item in obj] + return obj + + __all__ = [ "ExperimentConfig", "load_experiment_config", diff --git a/src/ml4t/engineer/config/feature_config.py b/src/ml4t/engineer/config/feature_config.py index 8a33e43..b4d310e 100644 --- a/src/ml4t/engineer/config/feature_config.py +++ b/src/ml4t/engineer/config/feature_config.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="misc,call-arg,arg-type" # ruff: noqa: UP006, UP045 """Feature evaluation configuration (Modules A, B, C). diff --git a/src/ml4t/engineer/config/labeling.py b/src/ml4t/engineer/config/labeling.py index 1b53359..78fd26d 100644 --- a/src/ml4t/engineer/config/labeling.py +++ b/src/ml4t/engineer/config/labeling.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="misc,no-any-return,arg-type" """Labeling configuration with Pydantic v2 serialization. This module provides config classes for ML labeling methods that extend BaseConfig, diff --git a/src/ml4t/engineer/config/preprocessing_config.py b/src/ml4t/engineer/config/preprocessing_config.py index f2145f3..6dcc79b 100644 --- a/src/ml4t/engineer/config/preprocessing_config.py +++ b/src/ml4t/engineer/config/preprocessing_config.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="misc,no-any-return,call-arg" """Preprocessing configuration with Pydantic v2 serialization. This module provides config classes for preprocessing (scalers) that extend BaseConfig, @@ -289,15 +288,18 @@ def create_scaler(self) -> BaseScaler | None: if self.scaler == "standard": return StandardScaler( + columns=self.columns, with_mean=self.with_mean, with_std=self.with_std, ) elif self.scaler == "minmax": return MinMaxScaler( + columns=self.columns, feature_range=self.feature_range, ) elif self.scaler == "robust": return RobustScaler( + columns=self.columns, with_centering=self.with_centering, with_scaling=self.with_scaling, quantile_range=self.quantile_range, diff --git a/src/ml4t/engineer/core/__init__.py b/src/ml4t/engineer/core/__init__.py index 0e81ab1..b4719ac 100644 --- a/src/ml4t/engineer/core/__init__.py +++ b/src/ml4t/engineer/core/__init__.py @@ -16,16 +16,11 @@ DataError, DataSchemaError, DataValidationError, - ImplementationNotAvailableError, - IndicatorError, # Deprecated InsufficientDataError, IntegrationError, - InvalidArgumentError, InvalidParameterError, # Base exception - QuantLabTAError, - # Backward compatibility aliases - TechnicalAnalysisError, + ML4TEngineerError, ValidationError, ) from ml4t.engineer.core.registry import ( @@ -96,7 +91,7 @@ "FeatureRegistry", "get_registry", # Exceptions - Base - "QuantLabTAError", + "ML4TEngineerError", # Exceptions - First-level (flat hierarchy) "ConfigurationError", "ValidationError", @@ -107,11 +102,6 @@ "ComputationError", "DataError", "IntegrationError", - # Exceptions - Backward compatibility aliases - "TechnicalAnalysisError", - "IndicatorError", # Deprecated - "InvalidArgumentError", - "ImplementationNotAvailableError", # Validation "validate_lag", "validate_list_length", diff --git a/src/ml4t/engineer/core/decorators.py b/src/ml4t/engineer/core/decorators.py index 66c079a..869938c 100644 --- a/src/ml4t/engineer/core/decorators.py +++ b/src/ml4t/engineer/core/decorators.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="misc,arg-type,assignment" """Feature registration decorators. Simple decorator-based registration for features with zero overhead. diff --git a/src/ml4t/engineer/core/exceptions.py b/src/ml4t/engineer/core/exceptions.py index 6396756..4a0a2a7 100644 --- a/src/ml4t/engineer/core/exceptions.py +++ b/src/ml4t/engineer/core/exceptions.py @@ -5,7 +5,7 @@ provide actionable error messages. Exception Hierarchy (Flat - D06 Pattern): - QuantLabTAError (base) + ML4TEngineerError (base) ├── ConfigurationError # Configuration and setup errors ├── ValidationError # Input validation failures (also inherits ValueError) ├── InvalidParameterError # Invalid parameters to indicators @@ -27,11 +27,10 @@ from __future__ import annotations -import warnings from typing import Any -class QuantLabTAError(Exception): +class ML4TEngineerError(Exception): """ Base exception class for all ML4T Engineer errors. @@ -44,7 +43,7 @@ class QuantLabTAError(Exception): cause: Original exception if error was wrapped Example: - >>> raise QuantLabTAError( + >>> raise ML4TEngineerError( ... "Operation failed", ... context={"operation": "compute_rsi", "reason": "insufficient_data"} ... ) @@ -98,7 +97,7 @@ def __repr__(self) -> str: # ============================================================================= -class ConfigurationError(QuantLabTAError): +class ConfigurationError(ML4TEngineerError): """ Configuration and setup errors. @@ -119,11 +118,11 @@ class ConfigurationError(QuantLabTAError): pass -class ValidationError(QuantLabTAError, ValueError): +class ValidationError(ML4TEngineerError, ValueError): """ Input validation failures. - Inherits from both QuantLabTAError (for library-specific catching) + Inherits from both ML4TEngineerError (for library-specific catching) and ValueError (for standard Python parameter error handling). Raised when: @@ -143,7 +142,7 @@ class ValidationError(QuantLabTAError, ValueError): pass -class InvalidParameterError(QuantLabTAError, ValueError): +class InvalidParameterError(ML4TEngineerError, ValueError): """ Invalid parameters provided to indicators. @@ -167,7 +166,7 @@ class InvalidParameterError(QuantLabTAError, ValueError): pass -class DataValidationError(QuantLabTAError): +class DataValidationError(ML4TEngineerError): """ Data validation failures. @@ -188,7 +187,7 @@ class DataValidationError(QuantLabTAError): pass -class DataSchemaError(QuantLabTAError): +class DataSchemaError(ML4TEngineerError): """ Schema validation failures. @@ -209,7 +208,7 @@ class DataSchemaError(QuantLabTAError): pass -class InsufficientDataError(QuantLabTAError): +class InsufficientDataError(ML4TEngineerError): """ Insufficient data for calculation. @@ -229,7 +228,7 @@ class InsufficientDataError(QuantLabTAError): pass -class ComputationError(QuantLabTAError): +class ComputationError(ML4TEngineerError): """ Calculation and numerical errors. @@ -250,7 +249,7 @@ class ComputationError(QuantLabTAError): pass -class DataError(QuantLabTAError): +class DataError(ML4TEngineerError): """ Data access and format errors. @@ -271,7 +270,7 @@ class DataError(QuantLabTAError): pass -class IntegrationError(QuantLabTAError): +class IntegrationError(ML4TEngineerError): """ External library integration errors. @@ -292,50 +291,13 @@ class IntegrationError(QuantLabTAError): pass -# ============================================================================= -# Backward Compatibility Aliases -# ============================================================================= - - -# Primary alias for historical naming -TechnicalAnalysisError = QuantLabTAError - - -class _DeprecatedIndicatorError(QuantLabTAError): - """Deprecated intermediate class - use QuantLabTAError or specific errors.""" - - def __init__( - self, - message: str, - context: dict[str, Any] | None = None, - cause: Exception | None = None, - ): - warnings.warn( - "IndicatorError is deprecated. Use QuantLabTAError or a specific " - "exception type (InsufficientDataError, ComputationError) instead.", - DeprecationWarning, - stacklevel=2, - ) - super().__init__(message, context, cause) - - -# IndicatorError was an intermediate class - deprecated but kept for backward compat -IndicatorError = _DeprecatedIndicatorError - -# InvalidArgumentError is an alias for InvalidParameterError -InvalidArgumentError = InvalidParameterError - -# ImplementationNotAvailableError maps to IntegrationError -ImplementationNotAvailableError = IntegrationError - - # ============================================================================= # Public API # ============================================================================= __all__ = [ # Base exception - "QuantLabTAError", + "ML4TEngineerError", # First-level exceptions (flat hierarchy) "ConfigurationError", "ValidationError", @@ -346,9 +308,4 @@ def __init__( "ComputationError", "DataError", "IntegrationError", - # Backward compatibility aliases - "TechnicalAnalysisError", - "IndicatorError", # Deprecated - "InvalidArgumentError", - "ImplementationNotAvailableError", ] diff --git a/src/ml4t/engineer/core/registry.py b/src/ml4t/engineer/core/registry.py index 661321a..8c00a56 100644 --- a/src/ml4t/engineer/core/registry.py +++ b/src/ml4t/engineer/core/registry.py @@ -59,7 +59,7 @@ class FeatureMetadata: """ name: str - func: Callable[..., pl.DataFrame | pl.LazyFrame] + func: Callable[..., pl.Expr | dict[str, pl.Expr] | pl.DataFrame | pl.LazyFrame] category: str description: str formula: str = "" diff --git a/src/ml4t/engineer/core/schemas.py b/src/ml4t/engineer/core/schemas.py index 02a4465..f291048 100644 --- a/src/ml4t/engineer/core/schemas.py +++ b/src/ml4t/engineer/core/schemas.py @@ -152,14 +152,7 @@ def validate_ohlcv_schema( for col in ohlcv_cols: if col in schema: dtype = schema[col] - if dtype not in [ - pl.Float32, - pl.Float64, - pl.Int32, - pl.Int64, - pl.UInt32, - pl.UInt64, - ]: + if not dtype.is_numeric(): raise DataSchemaError( f"Column '{col}' must be numeric, got {dtype}. " "OHLCV columns must be numeric types (float or int).", diff --git a/src/ml4t/engineer/core/validation.py b/src/ml4t/engineer/core/validation.py index a365a01..27304d0 100644 --- a/src/ml4t/engineer/core/validation.py +++ b/src/ml4t/engineer/core/validation.py @@ -169,24 +169,6 @@ def validate_column_exists(df: pl.DataFrame, column: str) -> None: raise ValueError(f"Column '{column}' not found. Available columns: {available}") -def validate_numeric_column(expr: pl.Expr, name: str = "column") -> None: - """Validate that expression is numeric. - - Parameters - ---------- - expr : pl.Expr - Expression to validate - name : str, default "column" - Parameter name for error messages - - Note - ---- - This is a placeholder as runtime type checking of expressions - is limited. Consider adding schema validation at the DataFrame level. - """ - # Limited validation possible at expression level - - def validate_positive(value: float, name: str = "value") -> None: """Validate that value is positive. diff --git a/src/ml4t/engineer/dataset.py b/src/ml4t/engineer/dataset.py index 35e3e2a..2e87e39 100644 --- a/src/ml4t/engineer/dataset.py +++ b/src/ml4t/engineer/dataset.py @@ -354,20 +354,7 @@ def split( # Apply preprocessing (train-only fit) if self._scaler is not None: - # Clone scaler for this fold (fresh fit) - fold_scaler = type(self._scaler)( - columns=self._scaler._columns, - ) - # Copy other attributes if they exist - for attr in ["with_mean", "with_std", "ddof"]: # StandardScaler - if hasattr(self._scaler, attr): - setattr(fold_scaler, attr, getattr(self._scaler, attr)) - for attr in ["feature_range"]: # MinMaxScaler - if hasattr(self._scaler, attr): - setattr(fold_scaler, attr, getattr(self._scaler, attr)) - for attr in ["with_centering", "with_scaling", "quantile_range"]: # RobustScaler - if hasattr(self._scaler, attr): - setattr(fold_scaler, attr, getattr(self._scaler, attr)) + fold_scaler = self._scaler.clone() # Fit on train, transform both X_train = fold_scaler.fit_transform(X_train_raw) diff --git a/src/ml4t/engineer/features/fdiff.py b/src/ml4t/engineer/features/fdiff.py index e47eda2..8b48dff 100644 --- a/src/ml4t/engineer/features/fdiff.py +++ b/src/ml4t/engineer/features/fdiff.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,arg-type,dict-item" """Fractional differencing for stationarity with memory preservation. This module implements the Fixed-Width Window Fractional Differencing (FFD) method, diff --git a/src/ml4t/engineer/features/math/max.py b/src/ml4t/engineer/features/math/max.py index c5af645..24538ba 100644 --- a/src/ml4t/engineer/features/math/max.py +++ b/src/ml4t/engineer/features/math/max.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ MAX - Highest value over a specified number of periods. diff --git a/src/ml4t/engineer/features/math/min.py b/src/ml4t/engineer/features/math/min.py index d3b4b31..771a5d3 100644 --- a/src/ml4t/engineer/features/math/min.py +++ b/src/ml4t/engineer/features/math/min.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ MIN - Lowest value over a specified number of periods. diff --git a/src/ml4t/engineer/features/math/sum.py b/src/ml4t/engineer/features/math/sum.py index 244ec91..7879a31 100644 --- a/src/ml4t/engineer/features/math/sum.py +++ b/src/ml4t/engineer/features/math/sum.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ SUM - Summation over a specified number of periods. diff --git a/src/ml4t/engineer/features/ml/cyclical_encode.py b/src/ml4t/engineer/features/ml/cyclical_encode.py index 5f1083c..ed27b42 100644 --- a/src/ml4t/engineer/features/ml/cyclical_encode.py +++ b/src/ml4t/engineer/features/ml/cyclical_encode.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="misc" import numpy as np import polars as pl @@ -18,6 +17,7 @@ normalized=False, formula="", ta_lib_compatible=False, + parameters={"period": 24.0}, ) def cyclical_encode( value: pl.Expr | str, diff --git a/src/ml4t/engineer/features/ml/directional_targets.py b/src/ml4t/engineer/features/ml/directional_targets.py index 26c040b..465a5db 100644 --- a/src/ml4t/engineer/features/ml/directional_targets.py +++ b/src/ml4t/engineer/features/ml/directional_targets.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="misc" import polars as pl from ml4t.engineer.core.decorators import feature diff --git a/src/ml4t/engineer/features/ml/interaction_features.py b/src/ml4t/engineer/features/ml/interaction_features.py index 6c15bf7..e66a438 100644 --- a/src/ml4t/engineer/features/ml/interaction_features.py +++ b/src/ml4t/engineer/features/ml/interaction_features.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="operator,assignment,union-attr,arg-type" import polars as pl from ml4t.engineer.core.decorators import feature diff --git a/src/ml4t/engineer/features/ml/rolling_entropy.py b/src/ml4t/engineer/features/ml/rolling_entropy.py index 5e8a78c..b8efb8f 100644 --- a/src/ml4t/engineer/features/ml/rolling_entropy.py +++ b/src/ml4t/engineer/features/ml/rolling_entropy.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="misc,no-any-return" """ Entropy Features for Financial Time Series. diff --git a/src/ml4t/engineer/features/ml/time_decay_weights.py b/src/ml4t/engineer/features/ml/time_decay_weights.py index 62c4a4a..02b5dea 100644 --- a/src/ml4t/engineer/features/ml/time_decay_weights.py +++ b/src/ml4t/engineer/features/ml/time_decay_weights.py @@ -11,10 +11,11 @@ name="time_decay_weights", category="ml", description="Time Decay Weights - exponentially decaying weights", - lookback=0, + lookback="lookback", normalized=False, formula="", ta_lib_compatible=False, + parameters={"lookback": 20}, ) def time_decay_weights( lookback: int, diff --git a/src/ml4t/engineer/features/momentum/adx.py b/src/ml4t/engineer/features/momentum/adx.py index 679b121..bee8c5c 100644 --- a/src/ml4t/engineer/features/momentum/adx.py +++ b/src/ml4t/engineer/features/momentum/adx.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,misc" """ ADX (Average Directional Movement Index) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/momentum/adxr.py b/src/ml4t/engineer/features/momentum/adxr.py index 55a3d71..f6d1776 100644 --- a/src/ml4t/engineer/features/momentum/adxr.py +++ b/src/ml4t/engineer/features/momentum/adxr.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,type-arg,attr-defined,arg-type,union-attr" """ ADXR - Average Directional Movement Index Rating. diff --git a/src/ml4t/engineer/features/momentum/apo.py b/src/ml4t/engineer/features/momentum/apo.py index 8bd5970..34cb9fd 100644 --- a/src/ml4t/engineer/features/momentum/apo.py +++ b/src/ml4t/engineer/features/momentum/apo.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Absolute Price Oscillator (APO) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/momentum/aroon.py b/src/ml4t/engineer/features/momentum/aroon.py index 052b590..7e86ac0 100644 --- a/src/ml4t/engineer/features/momentum/aroon.py +++ b/src/ml4t/engineer/features/momentum/aroon.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Aroon Indicators (AROON, AROONOSC) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/momentum/bop.py b/src/ml4t/engineer/features/momentum/bop.py index 6911963..64ea7b5 100644 --- a/src/ml4t/engineer/features/momentum/bop.py +++ b/src/ml4t/engineer/features/momentum/bop.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,arg-type,union-attr" """ BOP - Balance of Power. diff --git a/src/ml4t/engineer/features/momentum/cci.py b/src/ml4t/engineer/features/momentum/cci.py index 0557f54..13073ad 100644 --- a/src/ml4t/engineer/features/momentum/cci.py +++ b/src/ml4t/engineer/features/momentum/cci.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """Commodity Channel Index (CCI) implementation.""" import numpy as np diff --git a/src/ml4t/engineer/features/momentum/cmo.py b/src/ml4t/engineer/features/momentum/cmo.py index f2a22e1..9be505c 100644 --- a/src/ml4t/engineer/features/momentum/cmo.py +++ b/src/ml4t/engineer/features/momentum/cmo.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ CMO - Chande Momentum Oscillator. diff --git a/src/ml4t/engineer/features/momentum/directional.py b/src/ml4t/engineer/features/momentum/directional.py index 110dff6..c26231f 100644 --- a/src/ml4t/engineer/features/momentum/directional.py +++ b/src/ml4t/engineer/features/momentum/directional.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Directional Movement indicators - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/momentum/imi.py b/src/ml4t/engineer/features/momentum/imi.py index e98cea8..6c5e3f7 100644 --- a/src/ml4t/engineer/features/momentum/imi.py +++ b/src/ml4t/engineer/features/momentum/imi.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,union-attr" """ IMI - Intraday Momentum Index. diff --git a/src/ml4t/engineer/features/momentum/macd.py b/src/ml4t/engineer/features/momentum/macd.py index 0944355..5358d90 100644 --- a/src/ml4t/engineer/features/momentum/macd.py +++ b/src/ml4t/engineer/features/momentum/macd.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ MACD (Moving Average Convergence/Divergence) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/momentum/macdfix.py b/src/ml4t/engineer/features/momentum/macdfix.py index 7662ec7..6eaaa6b 100644 --- a/src/ml4t/engineer/features/momentum/macdfix.py +++ b/src/ml4t/engineer/features/momentum/macdfix.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ MACDFIX - Moving Average Convergence/Divergence Fix 12/26. diff --git a/src/ml4t/engineer/features/momentum/mfi.py b/src/ml4t/engineer/features/momentum/mfi.py index a84539c..65603fe 100644 --- a/src/ml4t/engineer/features/momentum/mfi.py +++ b/src/ml4t/engineer/features/momentum/mfi.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Money Flow Index (MFI) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/momentum/minus_dm.py b/src/ml4t/engineer/features/momentum/minus_dm.py index 79fd3fb..68b468b 100644 --- a/src/ml4t/engineer/features/momentum/minus_dm.py +++ b/src/ml4t/engineer/features/momentum/minus_dm.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,union-attr" """ MINUS_DM - Minus Directional Movement. diff --git a/src/ml4t/engineer/features/momentum/plus_dm.py b/src/ml4t/engineer/features/momentum/plus_dm.py index 06a2acd..3995f47 100644 --- a/src/ml4t/engineer/features/momentum/plus_dm.py +++ b/src/ml4t/engineer/features/momentum/plus_dm.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,union-attr" """ PLUS_DM - Plus Directional Movement. diff --git a/src/ml4t/engineer/features/momentum/ppo.py b/src/ml4t/engineer/features/momentum/ppo.py index f7ebc87..ad2e986 100644 --- a/src/ml4t/engineer/features/momentum/ppo.py +++ b/src/ml4t/engineer/features/momentum/ppo.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Percentage Price Oscillator (PPO) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/momentum/roc.py b/src/ml4t/engineer/features/momentum/roc.py index 8c4f323..963cee2 100644 --- a/src/ml4t/engineer/features/momentum/roc.py +++ b/src/ml4t/engineer/features/momentum/roc.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """Rate of Change (ROC) implementation.""" import numpy as np diff --git a/src/ml4t/engineer/features/momentum/rocp.py b/src/ml4t/engineer/features/momentum/rocp.py index 7214323..cc7759e 100644 --- a/src/ml4t/engineer/features/momentum/rocp.py +++ b/src/ml4t/engineer/features/momentum/rocp.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ ROCP - Rate of Change Percentage. diff --git a/src/ml4t/engineer/features/momentum/rocr.py b/src/ml4t/engineer/features/momentum/rocr.py index b89d564..0a4e1c5 100644 --- a/src/ml4t/engineer/features/momentum/rocr.py +++ b/src/ml4t/engineer/features/momentum/rocr.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ ROCR - Rate of Change Ratio. diff --git a/src/ml4t/engineer/features/momentum/rocr100.py b/src/ml4t/engineer/features/momentum/rocr100.py index b5d8b3a..aca4255 100644 --- a/src/ml4t/engineer/features/momentum/rocr100.py +++ b/src/ml4t/engineer/features/momentum/rocr100.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ ROCR100 - Rate of Change Ratio 100 scale. diff --git a/src/ml4t/engineer/features/momentum/rsi.py b/src/ml4t/engineer/features/momentum/rsi.py index 6863370..7950816 100644 --- a/src/ml4t/engineer/features/momentum/rsi.py +++ b/src/ml4t/engineer/features/momentum/rsi.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Relative Strength Index (RSI) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/momentum/sar.py b/src/ml4t/engineer/features/momentum/sar.py index c89fc8b..e6a4bba 100644 --- a/src/ml4t/engineer/features/momentum/sar.py +++ b/src/ml4t/engineer/features/momentum/sar.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Parabolic SAR (Stop and Reverse) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/momentum/stochastic.py b/src/ml4t/engineer/features/momentum/stochastic.py index cc07dfe..019f690 100644 --- a/src/ml4t/engineer/features/momentum/stochastic.py +++ b/src/ml4t/engineer/features/momentum/stochastic.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ STOCHASTIC - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/momentum/stochf.py b/src/ml4t/engineer/features/momentum/stochf.py index c520f41..863a296 100644 --- a/src/ml4t/engineer/features/momentum/stochf.py +++ b/src/ml4t/engineer/features/momentum/stochf.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="union-attr" """ STOCHF - Stochastic Fast. diff --git a/src/ml4t/engineer/features/momentum/trix.py b/src/ml4t/engineer/features/momentum/trix.py index 1e6b414..011e6db 100644 --- a/src/ml4t/engineer/features/momentum/trix.py +++ b/src/ml4t/engineer/features/momentum/trix.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ TRIX - 1-day Rate-Of-Change (ROC) of a Triple Smooth EMA - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/momentum/ultosc.py b/src/ml4t/engineer/features/momentum/ultosc.py index 5cb31ef..b5662d2 100644 --- a/src/ml4t/engineer/features/momentum/ultosc.py +++ b/src/ml4t/engineer/features/momentum/ultosc.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Ultimate Oscillator (ULTOSC) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/momentum/willr.py b/src/ml4t/engineer/features/momentum/willr.py index b765bfa..0e3e296 100644 --- a/src/ml4t/engineer/features/momentum/willr.py +++ b/src/ml4t/engineer/features/momentum/willr.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """Williams %R implementation.""" import numpy as np diff --git a/src/ml4t/engineer/features/price_transform/avgprice.py b/src/ml4t/engineer/features/price_transform/avgprice.py index 002ee81..0ebbef1 100644 --- a/src/ml4t/engineer/features/price_transform/avgprice.py +++ b/src/ml4t/engineer/features/price_transform/avgprice.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Average Price (AVGPRICE) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/price_transform/medprice.py b/src/ml4t/engineer/features/price_transform/medprice.py index 1c1c554..6e2d921 100644 --- a/src/ml4t/engineer/features/price_transform/medprice.py +++ b/src/ml4t/engineer/features/price_transform/medprice.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Median Price (MEDPRICE) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/price_transform/midprice.py b/src/ml4t/engineer/features/price_transform/midprice.py index 239ea94..0632537 100644 --- a/src/ml4t/engineer/features/price_transform/midprice.py +++ b/src/ml4t/engineer/features/price_transform/midprice.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ MIDPRICE - Midpoint Price over period. diff --git a/src/ml4t/engineer/features/price_transform/typprice.py b/src/ml4t/engineer/features/price_transform/typprice.py index 3a9c123..c689687 100644 --- a/src/ml4t/engineer/features/price_transform/typprice.py +++ b/src/ml4t/engineer/features/price_transform/typprice.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Typical Price (TYPPRICE) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/price_transform/wclprice.py b/src/ml4t/engineer/features/price_transform/wclprice.py index b09b41f..4af881f 100644 --- a/src/ml4t/engineer/features/price_transform/wclprice.py +++ b/src/ml4t/engineer/features/price_transform/wclprice.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Weighted Close Price (WCLPRICE) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/regime.py b/src/ml4t/engineer/features/regime.py index c85847f..f1ccba0 100644 --- a/src/ml4t/engineer/features/regime.py +++ b/src/ml4t/engineer/features/regime.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """Market regime identification features. Exports: @@ -427,8 +426,8 @@ def safe_hurst(x: pl.Series) -> float: description="Trend Intensity Index - measures trend strength", lookback="period", normalized=True, - value_range=(0.0, 100.0), - formula="TII = 100 * (closes_above_MA / period)", + value_range=(-100.0, 100.0), + formula="TII = 100 * (closes_above_MA / period), negative for downtrends", input_type="close", parameters={"period": 60}, tags=["regime", "trend-strength"], diff --git a/src/ml4t/engineer/features/risk.py b/src/ml4t/engineer/features/risk.py index 0518e4e..a9a5374 100644 --- a/src/ml4t/engineer/features/risk.py +++ b/src/ml4t/engineer/features/risk.py @@ -34,9 +34,7 @@ """ import numpy as np -import numpy.typing as npt import polars as pl -from numba import jit from scipy import stats from ml4t.engineer.core.decorators import feature @@ -211,62 +209,6 @@ def calculate_cvar(close: pl.Series) -> float: return mean - std * pl.lit(pdf_z / alpha) -@jit(nopython=True, cache=True) # type: ignore[misc] -def _calculate_drawdowns_nb( - close: npt.NDArray[np.float64], -) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]]: - """Calculate drawdown series, duration, and recovery time using Numba. - - Parameters - ---------- - close : npt.NDArray - Price series - - Returns - ------- - tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]] - (drawdown_series, drawdown_duration, time_to_recovery) - """ - n = len(close) - drawdowns = np.zeros(n) - durations = np.zeros(n) - recovery_times = np.zeros(n) - - running_max = close[0] - drawdown_start = 0 - in_drawdown = False - - for i in range(n): - if close[i] > running_max: - running_max = close[i] - - # Recovery occurred - if in_drawdown: - # Fill recovery time for the drawdown period - for j in range(drawdown_start, i): - recovery_times[j] = i - drawdown_start - in_drawdown = False - - # Calculate current drawdown - drawdowns[i] = (close[i] - running_max) / running_max - - # Track drawdown duration - if drawdowns[i] < 0: - if not in_drawdown: - drawdown_start = i - in_drawdown = True - durations[i] = i - drawdown_start + 1 - else: - durations[i] = 0 - - # Handle case where series ends in drawdown - if in_drawdown: - for j in range(drawdown_start, n): - recovery_times[j] = np.nan # Not recovered - - return drawdowns, durations, recovery_times - - @feature( name="maximum_drawdown", category="risk", diff --git a/src/ml4t/engineer/features/statistics/avgdev.py b/src/ml4t/engineer/features/statistics/avgdev.py index d8a3e5b..eb66422 100644 --- a/src/ml4t/engineer/features/statistics/avgdev.py +++ b/src/ml4t/engineer/features/statistics/avgdev.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ AVGDEV - Average Deviation. diff --git a/src/ml4t/engineer/features/statistics/linearreg.py b/src/ml4t/engineer/features/statistics/linearreg.py index c09f1af..0a9874a 100644 --- a/src/ml4t/engineer/features/statistics/linearreg.py +++ b/src/ml4t/engineer/features/statistics/linearreg.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ LINEARREG - Linear Regression. diff --git a/src/ml4t/engineer/features/statistics/linearreg_angle.py b/src/ml4t/engineer/features/statistics/linearreg_angle.py index 93e1ab3..c60a059 100644 --- a/src/ml4t/engineer/features/statistics/linearreg_angle.py +++ b/src/ml4t/engineer/features/statistics/linearreg_angle.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ LINEARREG_ANGLE - Linear Regression Angle. diff --git a/src/ml4t/engineer/features/statistics/linearreg_intercept.py b/src/ml4t/engineer/features/statistics/linearreg_intercept.py index 51c69bb..42a85b1 100644 --- a/src/ml4t/engineer/features/statistics/linearreg_intercept.py +++ b/src/ml4t/engineer/features/statistics/linearreg_intercept.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ LINEARREG_INTERCEPT - Linear Regression Intercept. diff --git a/src/ml4t/engineer/features/statistics/linearreg_slope.py b/src/ml4t/engineer/features/statistics/linearreg_slope.py index 29e678a..9699ff5 100644 --- a/src/ml4t/engineer/features/statistics/linearreg_slope.py +++ b/src/ml4t/engineer/features/statistics/linearreg_slope.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ LINEARREG_SLOPE - Linear Regression Slope. diff --git a/src/ml4t/engineer/features/statistics/stddev.py b/src/ml4t/engineer/features/statistics/stddev.py index 43773c1..e323403 100644 --- a/src/ml4t/engineer/features/statistics/stddev.py +++ b/src/ml4t/engineer/features/statistics/stddev.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ STDDEV (Standard Deviation) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/statistics/structural_break.py b/src/ml4t/engineer/features/statistics/structural_break.py index 2bb22e0..37125ab 100644 --- a/src/ml4t/engineer/features/statistics/structural_break.py +++ b/src/ml4t/engineer/features/statistics/structural_break.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="misc,no-any-return" """ Structural Break Detection Features. diff --git a/src/ml4t/engineer/features/statistics/tsf.py b/src/ml4t/engineer/features/statistics/tsf.py index 4f0c135..99d819f 100644 --- a/src/ml4t/engineer/features/statistics/tsf.py +++ b/src/ml4t/engineer/features/statistics/tsf.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ TSF - Time Series Forecast. diff --git a/src/ml4t/engineer/features/statistics/var.py b/src/ml4t/engineer/features/statistics/var.py index 7709c21..086ca66 100644 --- a/src/ml4t/engineer/features/statistics/var.py +++ b/src/ml4t/engineer/features/statistics/var.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ VAR - Variance over period. diff --git a/src/ml4t/engineer/features/trend/dema.py b/src/ml4t/engineer/features/trend/dema.py index b8120b1..90fec44 100644 --- a/src/ml4t/engineer/features/trend/dema.py +++ b/src/ml4t/engineer/features/trend/dema.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ DEMA (Double Exponential Moving Average) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/trend/donchian.py b/src/ml4t/engineer/features/trend/donchian.py index 4dfc1eb..5df5cf8 100644 --- a/src/ml4t/engineer/features/trend/donchian.py +++ b/src/ml4t/engineer/features/trend/donchian.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="operator,assignment,union-attr,call-arg" """ Donchian Channels - Price channel indicator. diff --git a/src/ml4t/engineer/features/trend/ema.py b/src/ml4t/engineer/features/trend/ema.py index 282ed33..51ddcd3 100644 --- a/src/ml4t/engineer/features/trend/ema.py +++ b/src/ml4t/engineer/features/trend/ema.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Exponential Moving Average (EMA) - TA-Lib compatible implementation. @@ -115,10 +114,11 @@ def ema_polars(column: str, period: int) -> pl.Expr: name="ema", category="trend", description="EMA - Exponential Moving Average", - lookback=0, + lookback="period", normalized=False, formula="", ta_lib_compatible=True, + parameters={"period": 20}, ) def ema( close: npt.NDArray[np.float64] | pl.Series | str, diff --git a/src/ml4t/engineer/features/trend/kama.py b/src/ml4t/engineer/features/trend/kama.py index 1f850de..1739c49 100644 --- a/src/ml4t/engineer/features/trend/kama.py +++ b/src/ml4t/engineer/features/trend/kama.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ KAMA - Kaufman's Adaptive Moving Average. diff --git a/src/ml4t/engineer/features/trend/midpoint.py b/src/ml4t/engineer/features/trend/midpoint.py index 8bc4dc1..7bd5c34 100644 --- a/src/ml4t/engineer/features/trend/midpoint.py +++ b/src/ml4t/engineer/features/trend/midpoint.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ MIDPOINT - Midpoint over period. diff --git a/src/ml4t/engineer/features/trend/sma.py b/src/ml4t/engineer/features/trend/sma.py index c1d796b..c30fd61 100644 --- a/src/ml4t/engineer/features/trend/sma.py +++ b/src/ml4t/engineer/features/trend/sma.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,return-value" """ Simple Moving Average (SMA) - TA-Lib compatible implementation. @@ -96,10 +95,11 @@ def sma_polars(column: str, period: int) -> pl.Expr: name="sma", category="trend", description="SMA - Simple Moving Average", - lookback=0, + lookback="period", normalized=False, formula="", ta_lib_compatible=True, + parameters={"period": 20}, ) def sma( close: npt.NDArray[np.float64] | pl.Series | str, diff --git a/src/ml4t/engineer/features/trend/t3.py b/src/ml4t/engineer/features/trend/t3.py index e671c96..91009b6 100644 --- a/src/ml4t/engineer/features/trend/t3.py +++ b/src/ml4t/engineer/features/trend/t3.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ T3 - Triple Exponential Moving Average (T3). diff --git a/src/ml4t/engineer/features/trend/tema.py b/src/ml4t/engineer/features/trend/tema.py index e99d13b..75920f3 100644 --- a/src/ml4t/engineer/features/trend/tema.py +++ b/src/ml4t/engineer/features/trend/tema.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ TEMA (Triple Exponential Moving Average) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/trend/trima.py b/src/ml4t/engineer/features/trend/trima.py index f6063be..01cb7d9 100644 --- a/src/ml4t/engineer/features/trend/trima.py +++ b/src/ml4t/engineer/features/trend/trima.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ TRIMA (Triangular Moving Average) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/trend/wma.py b/src/ml4t/engineer/features/trend/wma.py index e0290af..8852112 100644 --- a/src/ml4t/engineer/features/trend/wma.py +++ b/src/ml4t/engineer/features/trend/wma.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Weighted Moving Average (WMA) - TA-Lib compatible implementation. @@ -82,10 +81,11 @@ def wma_polars(column: str, period: int) -> pl.Expr: name="wma", category="trend", description="WMA - Weighted Moving Average", - lookback=0, + lookback="period", normalized=False, formula="", ta_lib_compatible=True, + parameters={"period": 20}, ) def wma( close: npt.NDArray[np.float64] | pl.Series | str, diff --git a/src/ml4t/engineer/features/volatility/atr.py b/src/ml4t/engineer/features/volatility/atr.py index 89834df..c51698c 100644 --- a/src/ml4t/engineer/features/volatility/atr.py +++ b/src/ml4t/engineer/features/volatility/atr.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ ATR (Average True Range) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/volatility/garch_forecast.py b/src/ml4t/engineer/features/volatility/garch_forecast.py index eee88dd..ff41553 100644 --- a/src/ml4t/engineer/features/volatility/garch_forecast.py +++ b/src/ml4t/engineer/features/volatility/garch_forecast.py @@ -45,18 +45,13 @@ def garch_volatility_forecast_nb( for t in range(1, n_valid): sigma2[t] = omega + alpha * valid_returns[t - 1] ** 2 + beta * sigma2[t - 1] - # Multi-step forecast + # Multi-step conditional expectation forecast (no lookahead) forecast = np.zeros(n_valid) for t in range(n_valid): - if t < n_valid - horizon: - # Use actual future volatility for in-sample - forecast[t] = sigma2[t + horizon] - else: - # Out-of-sample forecast - h_ahead = sigma2[t] - for _h in range(horizon): - h_ahead = omega + (alpha + beta) * h_ahead - forecast[t] = h_ahead + h_ahead = sigma2[t] + for _h in range(horizon): + h_ahead = omega + (alpha + beta) * h_ahead + forecast[t] = h_ahead # Map back to original positions valid_idx = 0 diff --git a/src/ml4t/engineer/features/volatility/natr.py b/src/ml4t/engineer/features/volatility/natr.py index cba39eb..4431461 100644 --- a/src/ml4t/engineer/features/volatility/natr.py +++ b/src/ml4t/engineer/features/volatility/natr.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Normalized Average True Range (NATR) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/volatility/trange.py b/src/ml4t/engineer/features/volatility/trange.py index 4d52f82..95c7143 100644 --- a/src/ml4t/engineer/features/volatility/trange.py +++ b/src/ml4t/engineer/features/volatility/trange.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ True Range (TRANGE) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/volume/ad.py b/src/ml4t/engineer/features/volume/ad.py index 55c27cf..176adb1 100644 --- a/src/ml4t/engineer/features/volume/ad.py +++ b/src/ml4t/engineer/features/volume/ad.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Chaikin A/D Line (AD) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/features/volume/adosc.py b/src/ml4t/engineer/features/volume/adosc.py index bc76932..db55673 100644 --- a/src/ml4t/engineer/features/volume/adosc.py +++ b/src/ml4t/engineer/features/volume/adosc.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ Chaikin A/D Oscillator (ADOSC) - TA-Lib compatible implementation. """ diff --git a/src/ml4t/engineer/features/volume/obv.py b/src/ml4t/engineer/features/volume/obv.py index a0bf0e2..5ba26b0 100644 --- a/src/ml4t/engineer/features/volume/obv.py +++ b/src/ml4t/engineer/features/volume/obv.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return" """ On Balance Volume (OBV) - TA-Lib compatible implementation. diff --git a/src/ml4t/engineer/labeling/__init__.py b/src/ml4t/engineer/labeling/__init__.py index 51009d2..2c56041 100644 --- a/src/ml4t/engineer/labeling/__init__.py +++ b/src/ml4t/engineer/labeling/__init__.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="arg-type" """Labeling module for ml4t.engineer. Provides generalized labeling functionality including triple-barrier method. diff --git a/src/ml4t/engineer/labeling/atr_barriers.py b/src/ml4t/engineer/labeling/atr_barriers.py index 2b29f00..465fcfc 100644 --- a/src/ml4t/engineer/labeling/atr_barriers.py +++ b/src/ml4t/engineer/labeling/atr_barriers.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="arg-type" """ ATR-Adjusted Triple Barrier Labeling @@ -71,7 +70,7 @@ from ml4t.engineer.core.exceptions import DataValidationError from ml4t.engineer.features.volatility import atr_polars from ml4t.engineer.labeling.triple_barrier import triple_barrier_labels -from ml4t.engineer.labeling.utils import resolve_labeling_columns +from ml4t.engineer.labeling.utils import resolve_labeling_columns, validate_price_no_nans if TYPE_CHECKING: from ml4t.engineer.config import DataContractConfig @@ -279,6 +278,8 @@ def atr_triple_barrier_labels( require_timestamp=True, ) + validate_price_no_nans(data, resolved_price_col) + # Compute ATR data_with_atr = data.with_columns( atr_polars("high", "low", "close", period=atr_period).alias("atr"), @@ -294,12 +295,22 @@ def atr_triple_barrier_labels( ) # Create barrier configuration with dynamic barriers - # Note: triple_barrier_labels requires int for max_holding_period, not None - # Use a large default (len of data) if not specified - # Note: LazyFrame doesn't support len(), but in practice this is always DataFrame - holding_period = ( - max_holding_bars if max_holding_bars is not None else len(data_with_barriers) # type: ignore[arg-type] - ) + # When max_holding_bars is None, we use len(data) as the horizon which makes + # barrier scanning O(N*L) where L=N. Warn for large datasets. + if max_holding_bars is None: + import warnings + + n = len(data_with_barriers) + if n > 5000: + warnings.warn( + f"max_holding_bars=None with {n:,} rows sets holding period to {n:,} bars. " + f"This makes barrier scanning O(N*{n:,}) which may be slow. " + f"Consider setting max_holding_bars explicitly (e.g., 50-200).", + stacklevel=2, + ) + holding_period: int | str = n + else: + holding_period = max_holding_bars barrier_config = LabelingConfig.triple_barrier( upper_barrier="upper_barrier_distance", diff --git a/src/ml4t/engineer/labeling/calendar.py b/src/ml4t/engineer/labeling/calendar.py index b299c4e..7123a01 100644 --- a/src/ml4t/engineer/labeling/calendar.py +++ b/src/ml4t/engineer/labeling/calendar.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,assignment,arg-type" """Calendar-aware labeling utilities. Provides session-aware barrier labeling that respects trading calendar gaps @@ -55,6 +54,7 @@ def __init__(self, gap_threshold_minutes: int = 30): """ self.gap_threshold = timedelta(minutes=gap_threshold_minutes) self._data: pl.DataFrame | None = None + self._session_breaks: list[datetime] | None = None def fit(self, data: pl.DataFrame, timestamp_col: str = "timestamp") -> SimpleTradingCalendar: """Learn session breaks from data gaps. diff --git a/src/ml4t/engineer/labeling/horizon_labels.py b/src/ml4t/engineer/labeling/horizon_labels.py index a7b465b..b1cea7f 100644 --- a/src/ml4t/engineer/labeling/horizon_labels.py +++ b/src/ml4t/engineer/labeling/horizon_labels.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,arg-type,call-arg,return-value,assignment" """Fixed horizon and trend scanning labeling methods. Provides simpler labeling methods for supervised learning: @@ -23,6 +22,7 @@ is_duration_string, parse_duration, resolve_labeling_columns, + validate_price_no_nans, ) if TYPE_CHECKING: @@ -158,6 +158,8 @@ def fixed_time_horizon_labels( "Provide timestamp_col parameter or ensure data has a datetime column.", ) + validate_price_no_nans(data, resolved_price_col) + if is_time_based: return _time_based_horizon_labels( data=data, @@ -302,6 +304,70 @@ def _time_based_horizon_labels( return data.with_columns(label.alias(label_name)) +def _trend_scanning_single_group( + data: pl.DataFrame, + min_window: int, + max_window: int, + step: int, + price_col: str, + timestamp_col: str | None, +) -> pl.DataFrame: + """Apply trend scanning to a single asset/group.""" + from scipy import stats + + # Sort data chronologically for correct forward scanning + if timestamp_col: + data = data.sort(timestamp_col) + + # Extract prices as numpy array for faster computation + prices = data[price_col].to_numpy() + n = len(prices) + + # Initialize result arrays + labels = np.full(n, np.nan) + t_values = np.full(n, np.nan) + windows = np.full(n, np.nan) + + # Scan each observation + for i in range(n - min_window): + best_t = 0.0 + best_window = min_window + + # Scan windows of different lengths + for window in range(min_window, min(max_window, n - i), step): + # Extract window + window_prices = prices[i : i + window] + x = np.arange(window) + y = window_prices + + # Fit linear regression + try: + slope, intercept, r_value, p_value, std_err = stats.linregress(x, y) + + # Compute t-statistic + t_stat = slope / std_err if std_err > 0 else 0.0 + + # Keep window with highest |t| + if abs(t_stat) > abs(best_t): + best_t = t_stat + best_window = window + except (ValueError, RuntimeError): + # Handle numerical issues + continue + + # Assign label based on trend direction + labels[i] = 1 if best_t > 0 else -1 + t_values[i] = best_t + windows[i] = best_window + + # Add results to dataframe + label_series = pl.Series("label", labels).fill_nan(None).cast(pl.Int8) + t_value_series = pl.Series("t_value", t_values) + window_series = pl.Series("optimal_window", windows).fill_nan(None).cast(pl.Int32) + + return data.with_columns([label_series, t_value_series, window_series]) + + def trend_scanning_labels( data: pl.DataFrame, min_window: int = 5, @@ -309,6 +375,7 @@ def trend_scanning_labels( step: int = 1, price_col: str | None = None, timestamp_col: str | None = None, + group_col: str | list[str] | None = None, *, config: LabelingConfig | None = None, contract: DataContractConfig | None = None, @@ -337,6 +404,10 @@ def trend_scanning_labels( timestamp_col : str | None, default None Column to use for chronological sorting. If None, auto-detects from column dtype (pl.Datetime, pl.Date). Required for correct scanning. + group_col : str | list[str] | None, default None + Column(s) to group by for per-asset labels. If None, auto-detects from + common column names: 'symbol', 'product', 'ticker'. + Pass an empty list explicitly to disable grouping. config : LabelingConfig | None, default None Optional column contract source. If provided, `price_col` and `timestamp_col` default to config values when omitted. @@ -358,6 +429,9 @@ def trend_scanning_labels( >>> >>> # Fast scanning with larger steps >>> labeled = trend_scanning_labels(df, min_window=10, max_window=100, step=5) + >>> + >>> # Panel data: per-asset scanning + >>> labeled = trend_scanning_labels(df, group_col="symbol") Notes ----- @@ -373,6 +447,9 @@ def trend_scanning_labels( - More robust than fixed horizons - Computationally expensive (O(n * m) where m = window range) + **Important**: Data is automatically sorted by [group_col, timestamp] before + scanning. This is required because the algorithm scans forward in row order. + References ---------- .. [1] De Prado, M.L. (2018). Advances in Financial Machine Learning. Wiley. @@ -382,84 +459,50 @@ def trend_scanning_labels( -------- fixed_time_horizon_labels : Simple fixed-horizon labeling triple_barrier_labels : Path-dependent labeling with barriers - - Notes - ----- - **Important**: Data is automatically sorted by timestamp before scanning. - This is required because the algorithm scans forward in row order. - The result is returned sorted chronologically. """ - from scipy import stats - if min_window < 2: raise ValueError("min_window must be at least 2") if max_window <= min_window: raise ValueError("max_window must be greater than min_window") if step < 1: raise ValueError("step must be at least 1") - resolved_price_col, resolved_ts_col, _ = resolve_labeling_columns( + resolved_price_col, resolved_ts_col, group_cols = resolve_labeling_columns( data=data, price_col=price_col, timestamp_col=timestamp_col, - group_col=[], + group_col=group_col, config=config, contract=contract, ) - # Sort data chronologically for correct forward scanning - if resolved_ts_col: - data = data.sort(resolved_ts_col) - - # Extract prices as numpy array for faster computation - prices = data[resolved_price_col].to_numpy() - n = len(prices) - - # Initialize result arrays - labels = np.full(n, np.nan) - t_values = np.full(n, np.nan) - windows = np.full(n, np.nan) - - # Scan each observation - for i in range(n - min_window): - best_t = 0.0 - best_window = min_window - - # Scan windows of different lengths - for window in range(min_window, min(max_window, n - i), step): - # Extract window - window_prices = prices[i : i + window] - x = np.arange(window) - y = window_prices + validate_price_no_nans(data, resolved_price_col) - # Fit linear regression - try: - slope, intercept, r_value, p_value, std_err = stats.linregress(x, y) - - # Compute t-statistic - t_stat = slope / std_err if std_err > 0 else 0.0 - - # Keep window with highest |t| - if abs(t_stat) > abs(best_t): - best_t = t_stat - best_window = window - except (ValueError, RuntimeError): - # Handle numerical issues - continue - - # Assign label based on trend direction - labels[i] = 1 if best_t > 0 else -1 - t_values[i] = best_t - windows[i] = best_window - - # Add results to dataframe - # Convert NaN to None for Polars compatibility - label_series = pl.Series("label", labels) - label_series = label_series.fill_nan(None).cast(pl.Int8) - - t_value_series = pl.Series("t_value", t_values) - window_series = pl.Series("optimal_window", windows).fill_nan(None).cast(pl.Int32) + if group_cols: + sort_cols = group_cols + ([resolved_ts_col] if resolved_ts_col else []) + sorted_data = data.sort(sort_cols) + grouped_frames = sorted_data.partition_by(group_cols, maintain_order=True) + + grouped_results = [ + _trend_scanning_single_group( + data=group_df, + min_window=min_window, + max_window=max_window, + step=step, + price_col=resolved_price_col, + timestamp_col=resolved_ts_col, + ) + for group_df in grouped_frames + ] + return pl.concat(grouped_results, how="vertical") - return data.with_columns([label_series, t_value_series, window_series]) + return _trend_scanning_single_group( + data=data, + min_window=min_window, + max_window=max_window, + step=step, + price_col=resolved_price_col, + timestamp_col=resolved_ts_col, + ) __all__ = [ diff --git a/src/ml4t/engineer/labeling/numba_ops.py b/src/ml4t/engineer/labeling/numba_ops.py index d605f99..5d357bf 100644 --- a/src/ml4t/engineer/labeling/numba_ops.py +++ b/src/ml4t/engineer/labeling/numba_ops.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,arg-type,call-arg,return-value,assignment" """Numba JIT-compiled operations for triple barrier labeling. These are internal functions used by the triple barrier implementation. @@ -185,6 +184,10 @@ def _check_barrier_touch( or (side == 0 and (low_price <= lower_price or low_price <= trailing_stop_price)) ) + # When both barriers are breached in the same bar (e.g., gap or high-volatility), + # upper barrier (profit target) takes priority. This is consistent with De Prado's + # AFML reference implementation. For conservative (stop-loss-first) resolution, + # use higher-frequency data to reduce intrabar ambiguity. if upper_hit: return 1 if lower_hit: @@ -328,11 +331,11 @@ def _apply_triple_barrier_nb( closes: npt.NDArray[np.float64], highs: npt.NDArray[np.float64], lows: npt.NDArray[np.float64], - event_times: npt.NDArray[np.float64], + event_indices: npt.NDArray[np.intp], upper_barriers: npt.NDArray[np.float64], lower_barriers: npt.NDArray[np.float64], - max_periods: npt.NDArray[np.float64], - sides: npt.NDArray[np.float64], + max_periods: npt.NDArray[np.int64], + sides: npt.NDArray[np.int32], trailing_stops: npt.NDArray[np.float64], ) -> tuple[ npt.NDArray[np.float64], @@ -355,7 +358,7 @@ def _apply_triple_barrier_nb( tuple of arrays (labels, label_indices, label_prices, label_returns, bar_durations) """ - n_events = len(event_times) + n_events = len(event_indices) n_prices = len(closes) # Output arrays @@ -366,7 +369,7 @@ def _apply_triple_barrier_nb( bar_durations = np.zeros(n_events, dtype=np.int64) for i in range(n_events): - event_idx = event_times[i] + event_idx = event_indices[i] upper = upper_barriers[i] lower = lower_barriers[i] max_period = max_periods[i] diff --git a/src/ml4t/engineer/labeling/triple_barrier.py b/src/ml4t/engineer/labeling/triple_barrier.py index e5977d8..7ff4b58 100644 --- a/src/ml4t/engineer/labeling/triple_barrier.py +++ b/src/ml4t/engineer/labeling/triple_barrier.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,arg-type,call-arg,return-value,assignment" """Triple barrier labeling implementation. Implements the generalized triple-barrier labeling method for financial machine learning. @@ -32,6 +31,7 @@ parse_duration, resolve_labeling_columns, time_horizon_to_bars, + validate_price_no_nans, ) @@ -139,8 +139,18 @@ def _prepare_barrier_arrays( elif config.trailing_stop is True: if config.lower_barrier is not None and isinstance(config.lower_barrier, int | float): trailing_stops = np.full(n_events, abs(float(config.lower_barrier))) + elif config.lower_barrier is not None and isinstance(config.lower_barrier, str): + # Column-name lower barrier (e.g., ATR-based): use per-event values + if config.lower_barrier not in data.columns: + raise DataValidationError( + f"Lower barrier column '{config.lower_barrier}' not found" + ) + trailing_stops = np.abs(data[config.lower_barrier].to_numpy()[event_indices]) else: - trailing_stops = np.full(n_events, 0.01) + raise DataValidationError( + "trailing_stop=True requires either a numeric lower_barrier to derive the " + "trail distance, or an explicit float value for trailing_stop (e.g., 0.02)." + ) elif isinstance(config.trailing_stop, int | float): trailing_stops = np.full(n_events, float(config.trailing_stop)) else: @@ -269,7 +279,9 @@ def _triple_barrier_labels_single_group( label_time=pl.lit(None, dtype=pl.Int64), label_price=pl.lit(None, dtype=pl.Float64), label_return=pl.lit(None, dtype=pl.Float64), - weight=pl.lit(None, dtype=pl.Float64), + label_bars=pl.lit(None, dtype=pl.Int64), + label_duration=pl.lit(None, dtype=pl.Utf8), + barrier_hit=pl.lit(None, dtype=pl.Utf8), ) else: event_indices = np.arange(len(data)) @@ -418,6 +430,8 @@ def triple_barrier_labels( "triple_barrier_labels requires LabelingConfig.method='triple_barrier'." ) + validate_price_no_nans(data, resolved_price_col) + if group_cols: sort_cols = group_cols + ([resolved_ts_col] if resolved_ts_col else []) sorted_data = data.sort(sort_cols) diff --git a/src/ml4t/engineer/labeling/uniqueness.py b/src/ml4t/engineer/labeling/uniqueness.py index 7c1e832..acd9c01 100644 --- a/src/ml4t/engineer/labeling/uniqueness.py +++ b/src/ml4t/engineer/labeling/uniqueness.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="no-any-return,arg-type,call-arg,return-value,assignment" """Label uniqueness and sample weighting functions. Implements De Prado's methods from AFML Chapter 4 for: diff --git a/src/ml4t/engineer/labeling/utils.py b/src/ml4t/engineer/labeling/utils.py index 49fdf05..5bb0824 100644 --- a/src/ml4t/engineer/labeling/utils.py +++ b/src/ml4t/engineer/labeling/utils.py @@ -19,6 +19,27 @@ _DATETIME_TYPES = (pl.Datetime, pl.Date) _DEFAULT_GROUP_COLS = ("symbol", "product", "ticker", "asset", "asset_id") + +def validate_price_no_nans(data: pl.DataFrame, price_col: str) -> None: + """Validate that price column has no NaN values. + + NaN prices produce silently wrong labels. This check runs before + any labeling computation to catch data issues early. + + Raises + ------ + DataValidationError + If price column contains NaN values. + """ + null_count = data[price_col].null_count() + nan_count = data[price_col].is_nan().sum() if data[price_col].dtype.is_float() else 0 + total_bad = null_count + nan_count + if total_bad > 0: + raise DataValidationError( + f"Price column '{price_col}' contains {total_bad} null/NaN values " + f"(out of {len(data)} rows). Clean data before labeling." + ) + # Duration string regex pattern (e.g., "1h", "30m", "1d2h30m") _DURATION_PATTERN = re.compile( r"^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$", diff --git a/src/ml4t/engineer/logging/config.py b/src/ml4t/engineer/logging/config.py index 6e4426a..eaecc81 100644 --- a/src/ml4t/engineer/logging/config.py +++ b/src/ml4t/engineer/logging/config.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="arg-type,dict-item" """Logging configuration utilities for ml4t.engineer. Provides configuration options and presets for different logging scenarios. diff --git a/src/ml4t/engineer/logging/core.py b/src/ml4t/engineer/logging/core.py index a959f38..7b957d0 100644 --- a/src/ml4t/engineer/logging/core.py +++ b/src/ml4t/engineer/logging/core.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="arg-type,call-arg,return-value,assignment,operator" """Core logging functionality for ml4t.engineer. Exports: diff --git a/src/ml4t/engineer/preprocessing.py b/src/ml4t/engineer/preprocessing.py index 472c38d..95620f7 100644 --- a/src/ml4t/engineer/preprocessing.py +++ b/src/ml4t/engineer/preprocessing.py @@ -179,6 +179,22 @@ def fit_transform(self, X: pl.DataFrame) -> pl.DataFrame: """ return self.fit(X).transform(X) + def clone(self) -> BaseScaler: + """Create an unfitted copy of this scaler with the same parameters. + + Returns + ------- + BaseScaler + New scaler instance with the same configuration but no fitted state. + """ + import copy + + new = copy.copy(self) + new._fitted_columns = [] + new._statistics = {} + new._is_fitted = False + return new + def to_dict(self) -> dict[str, Any]: """Serialize scaler to dictionary. @@ -287,26 +303,27 @@ def _compute_statistics( def _apply_transform(self, X: pl.DataFrame) -> pl.DataFrame: """Apply z-score normalization.""" + fitted_set = set(self._fitted_columns) exprs = [] - for col in self._fitted_columns: - mean_val = self._statistics[col]["mean"] - std_val = self._statistics[col]["std"] - - if self.with_mean and self.with_std: - expr = ((pl.col(col) - mean_val) / std_val).alias(col) - elif self.with_mean: - expr = (pl.col(col) - mean_val).alias(col) - elif self.with_std: - expr = (pl.col(col) / std_val).alias(col) + for col in X.columns: + if col in fitted_set: + mean_val = self._statistics[col]["mean"] + std_val = self._statistics[col]["std"] + + if self.with_mean and self.with_std: + expr = ((pl.col(col) - mean_val) / std_val).alias(col) + elif self.with_mean: + expr = (pl.col(col) - mean_val).alias(col) + elif self.with_std: + expr = (pl.col(col) / std_val).alias(col) + else: + expr = pl.col(col) else: expr = pl.col(col) exprs.append(expr) - # Keep non-fitted columns unchanged - other_cols = [pl.col(c) for c in X.columns if c not in self._fitted_columns] - - return X.select(exprs + other_cols) + return X.select(exprs) class MinMaxScaler(BaseScaler): @@ -341,11 +358,16 @@ def _compute_statistics( stats = {} for col in columns: series = X[col].drop_nulls() - # Note: min/max on numeric columns return numeric types - min_val = float(series.min()) # type: ignore[arg-type] - max_val = float(series.max()) # type: ignore[arg-type] - # Handle constant column (min == max) + # Handle empty series (all nulls) + if len(series) == 0: + min_val = 0.0 + max_val = 0.0 + else: + min_val = float(series.min()) # type: ignore[arg-type] + max_val = float(series.max()) # type: ignore[arg-type] + + # Handle constant column (min == max) or empty range_val = max_val - min_val if range_val == 0.0: range_val = 1.0 @@ -357,20 +379,21 @@ def _apply_transform(self, X: pl.DataFrame) -> pl.DataFrame: """Apply min-max scaling.""" target_min, target_max = self.feature_range target_range = target_max - target_min + fitted_set = set(self._fitted_columns) exprs = [] - for col in self._fitted_columns: - min_val = self._statistics[col]["min"] - range_val = self._statistics[col]["range"] - - # Scale to [0, 1] then to target range - expr = (((pl.col(col) - min_val) / range_val) * target_range + target_min).alias(col) + for col in X.columns: + if col in fitted_set: + min_val = self._statistics[col]["min"] + range_val = self._statistics[col]["range"] + expr = (((pl.col(col) - min_val) / range_val) * target_range + target_min).alias( + col + ) + else: + expr = pl.col(col) exprs.append(expr) - # Keep non-fitted columns unchanged - other_cols = [pl.col(c) for c in X.columns if c not in self._fitted_columns] - - return X.select(exprs + other_cols) + return X.select(exprs) class RobustScaler(BaseScaler): @@ -418,42 +441,47 @@ def _compute_statistics( for col in columns: series = X[col].drop_nulls() - # Note: median/quantile on numeric columns return numeric types - median_val = float(series.median()) if self.with_centering else 0.0 # type: ignore[arg-type] - if self.with_scaling: - q1 = float(series.quantile(q_low)) # type: ignore[arg-type] - q3 = float(series.quantile(q_high)) # type: ignore[arg-type] - iqr_val = q3 - q1 - if iqr_val == 0.0: - iqr_val = 1.0 - else: + # Handle empty series (all nulls) + if len(series) == 0: + median_val = 0.0 iqr_val = 1.0 + else: + median_val = float(series.median()) if self.with_centering else 0.0 # type: ignore[arg-type] + if self.with_scaling: + q1 = float(series.quantile(q_low)) # type: ignore[arg-type] + q3 = float(series.quantile(q_high)) # type: ignore[arg-type] + iqr_val = q3 - q1 + if iqr_val == 0.0: + iqr_val = 1.0 + else: + iqr_val = 1.0 stats[col] = {"median": median_val, "iqr": iqr_val} return stats def _apply_transform(self, X: pl.DataFrame) -> pl.DataFrame: """Apply robust scaling.""" + fitted_set = set(self._fitted_columns) exprs = [] - for col in self._fitted_columns: - median_val = self._statistics[col]["median"] - iqr_val = self._statistics[col]["iqr"] - - if self.with_centering and self.with_scaling: - expr = ((pl.col(col) - median_val) / iqr_val).alias(col) - elif self.with_centering: - expr = (pl.col(col) - median_val).alias(col) - elif self.with_scaling: - expr = (pl.col(col) / iqr_val).alias(col) + for col in X.columns: + if col in fitted_set: + median_val = self._statistics[col]["median"] + iqr_val = self._statistics[col]["iqr"] + + if self.with_centering and self.with_scaling: + expr = ((pl.col(col) - median_val) / iqr_val).alias(col) + elif self.with_centering: + expr = (pl.col(col) - median_val).alias(col) + elif self.with_scaling: + expr = (pl.col(col) / iqr_val).alias(col) + else: + expr = pl.col(col) else: expr = pl.col(col) exprs.append(expr) - # Keep non-fitted columns unchanged - other_cols = [pl.col(c) for c in X.columns if c not in self._fitted_columns] - - return X.select(exprs + other_cols) + return X.select(exprs) # ============================================================================= diff --git a/src/ml4t/engineer/relationships/plot_correlation.py b/src/ml4t/engineer/relationships/plot_correlation.py index b30f5af..d1f584a 100644 --- a/src/ml4t/engineer/relationships/plot_correlation.py +++ b/src/ml4t/engineer/relationships/plot_correlation.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="arg-type,assignment" """Correlation matrix visualization. This module provides plotting functions for correlation matrices. diff --git a/src/ml4t/engineer/selection/__init__.py b/src/ml4t/engineer/selection/__init__.py index 1db1822..401766b 100644 --- a/src/ml4t/engineer/selection/__init__.py +++ b/src/ml4t/engineer/selection/__init__.py @@ -1,44 +1,23 @@ -"""Feature selection for ML pipelines. +"""Feature selection has moved to ml4t-diagnostic. -This module provides systematic feature selection with multiple criteria: -- IC filtering (predictive power) -- Importance filtering (MDI/permutation/SHAP) -- Correlation filtering (redundancy removal) -- Drift filtering (stability) +Use ``ml4t.diagnostic.selection`` instead:: -.. note:: + from ml4t.diagnostic.selection import FeatureSelector - Requires ``ml4t-diagnostic`` for feature-outcome analysis. - Install with: ``pip install ml4t-diagnostic`` - -Example: - >>> from ml4t.engineer.selection import FeatureSelector - >>> from ml4t.diagnostic.evaluation import FeatureOutcome # Requires ml4t-diagnostic - >>> from ml4t.engineer.relationships import compute_correlation_matrix - >>> - >>> # Analyze features - >>> analyzer = FeatureOutcome() - >>> results = analyzer.run_analysis(features_df, returns_df) - >>> corr_matrix = compute_correlation_matrix(features_df) - >>> - >>> # Select features - >>> selector = FeatureSelector(results, corr_matrix) - >>> selector.run_pipeline([ - ... ("ic", {"threshold": 0.02}), - ... ("correlation", {"threshold": 0.8}), - ... ("importance", {"threshold": 0.01, "method": "mdi"}) - ... ]) - >>> selected = selector.get_selected_features() +Install with: ``pip install ml4t-diagnostic`` """ -from ml4t.engineer.selection.systematic import ( - FeatureSelector, - SelectionReport, - SelectionStep, -) - -__all__ = [ +_MOVED_EXPORTS = { "FeatureSelector", "SelectionReport", "SelectionStep", -] +} + + +def __getattr__(name: str) -> object: + if name in _MOVED_EXPORTS: + raise ImportError( + f"ml4t.engineer.selection.{name} has moved to ml4t-diagnostic. " + f"Use: from ml4t.diagnostic.selection import {name}" + ) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/ml4t/engineer/selection/systematic.py b/src/ml4t/engineer/selection/systematic.py deleted file mode 100644 index c64cddf..0000000 --- a/src/ml4t/engineer/selection/systematic.py +++ /dev/null @@ -1,736 +0,0 @@ -"""Systematic feature selection for ML pipelines. - -Exports: - FeatureSelector - Main class for systematic feature selection - .filter_by_ic(threshold=0.02) - Filter by information coefficient - .filter_by_importance(threshold, method="mdi") - Filter by importance - .filter_by_correlation(threshold=0.8) - Remove correlated features - .filter_by_drift(threshold=0.2) - Remove drifting features - .run_pipeline(steps) - Execute multiple filters in sequence - .get_selected_features() -> list[str] - .get_selection_report() -> SelectionReport - - SelectionStep - Dataclass for individual filter step results - SelectionReport - Dataclass for full selection pipeline results - -This module provides a comprehensive feature selection workflow that combines -multiple filtering criteria: - -- **IC Filtering**: Select features with strong information coefficient -- **Importance Filtering**: Select features based on MDI/permutation/SHAP importance -- **Correlation Filtering**: Remove redundant highly correlated features -- **Drift Filtering**: Remove features with unstable distributions - -The FeatureSelector class supports both individual filters and automated pipelines -that execute multiple filters in sequence. - -.. note:: - - This module requires ``ml4t-diagnostic`` for feature-outcome analysis. - Install with: ``pip install ml4t-diagnostic`` - -Example - Basic Usage: - >>> from ml4t.engineer.selection import FeatureSelector - >>> from ml4t.diagnostic.evaluation import FeatureOutcome # Requires ml4t-diagnostic - >>> from ml4t.engineer.relationships import compute_correlation_matrix - >>> - >>> # Run feature-outcome analysis - >>> analyzer = FeatureOutcome() - >>> results = analyzer.run_analysis(features_df, returns_df) - >>> - >>> # Compute correlation matrix - >>> corr_matrix = compute_correlation_matrix(features_df) - >>> - >>> # Create selector - >>> selector = FeatureSelector( - ... outcome_results=results, - ... correlation_matrix=corr_matrix - ... ) - >>> - >>> # Apply individual filters - >>> selector.filter_by_ic(threshold=0.02) - >>> selector.filter_by_correlation(threshold=0.8) - >>> selected = selector.get_selected_features() - >>> print(f"Selected {len(selected)} features") - -Example - Pipeline: - >>> # Run automated pipeline - >>> selector = FeatureSelector(results, corr_matrix) - >>> selector.run_pipeline([ - ... ("ic", {"threshold": 0.02, "min_periods": 20}), - ... ("correlation", {"threshold": 0.8}), - ... ("importance", {"threshold": 0.01, "method": "mdi"}) - ... ]) - >>> report = selector.get_selection_report() - >>> print(report) - -Example - With Drift: - >>> # Include drift filtering - >>> selector = FeatureSelector(results, corr_matrix) - >>> selector.filter_by_drift(threshold=0.2) # PSI threshold - >>> selector.filter_by_importance(threshold=0.05, method="shap") -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Literal - -import polars as pl - -# FeatureOutcomeResult is now in ml4t-diagnostic -# Import at runtime only when needed to avoid hard dependency -if TYPE_CHECKING: - from ml4t.diagnostic.evaluation import FeatureOutcomeResult - - -@dataclass -class SelectionStep: - """Record of a single selection step. - - Attributes: - step_name: Name of the filter applied - parameters: Parameters used for the filter - features_before: Number of features before filter - features_after: Number of features after filter - features_removed: List of features removed in this step - features_kept: List of features kept after this step - reasoning: Explanation of why features were removed - """ - - step_name: str - parameters: dict[str, Any] - features_before: int - features_after: int - features_removed: list[str] - features_kept: list[str] - reasoning: str - - def summary(self) -> str: - """Generate summary of this selection step.""" - pct_removed = 100 * len(self.features_removed) / max(1, self.features_before) - return ( - f"{self.step_name}: {self.features_before} → {self.features_after} " - f"({len(self.features_removed)} removed, {pct_removed:.1f}%)\n" - f" Parameters: {self.parameters}\n" - f" Reasoning: {self.reasoning}" - ) - - -@dataclass -class SelectionReport: - """Complete feature selection report. - - Attributes: - initial_features: Features at start of selection - final_features: Features after all filters - steps: List of selection steps applied - total_removed: Total number of features removed - removal_rate: Percentage of features removed - """ - - initial_features: list[str] - final_features: list[str] - steps: list[SelectionStep] = field(default_factory=list) - total_removed: int = field(init=False) - removal_rate: float = field(init=False) - - def __post_init__(self) -> None: - """Calculate derived fields.""" - self.total_removed = len(self.initial_features) - len(self.final_features) - self.removal_rate = 100 * self.total_removed / max(1, len(self.initial_features)) - - def summary(self) -> str: - """Generate comprehensive selection report.""" - lines = [ - "=" * 70, - "Feature Selection Report", - "=" * 70, - f"Initial Features: {len(self.initial_features)}", - f"Final Features: {len(self.final_features)}", - f"Removed: {self.total_removed} ({self.removal_rate:.1f}%)", - "", - "Selection Pipeline:", - "-" * 70, - ] - - for i, step in enumerate(self.steps, 1): - lines.append(f"\nStep {i}: {step.summary()}") - - lines.extend( - [ - "", - "-" * 70, - "Final Selected Features:", - "-" * 70, - ] - ) - for feature in sorted(self.final_features): - lines.append(f" ✓ {feature}") - - lines.append("=" * 70) - - return "\n".join(lines) - - -class FeatureSelector: - """Systematic feature selection with multiple filtering criteria. - - This class provides a comprehensive feature selection workflow that combines - IC analysis, importance scoring, correlation filtering, and drift detection. - - .. note:: - - Requires ``ml4t-diagnostic`` package for feature-outcome analysis. - Install with: ``pip install ml4t-diagnostic`` - - Parameters - ---------- - outcome_results : FeatureOutcomeResult - Results from feature-outcome analysis (IC, importance, drift). - Obtained from ``ml4t.diagnostic.evaluation.FeatureOutcome``. - correlation_matrix : pl.DataFrame, optional - Feature correlation matrix from compute_correlation_matrix() - initial_features : list[str], optional - Initial set of features to select from. - If None, uses all features from outcome_results. - - Attributes - ---------- - selected_features : set[str] - Current set of selected features (updated by filters) - removed_features : set[str] - Features removed by filters - selection_steps : list[SelectionStep] - History of selection steps applied - """ - - def __init__( - self, - outcome_results: FeatureOutcomeResult, - correlation_matrix: pl.DataFrame | None = None, - initial_features: list[str] | None = None, - ): - """Initialize feature selector. - - Args: - outcome_results: Feature-outcome analysis results - correlation_matrix: Optional correlation matrix for correlation filtering - initial_features: Optional initial feature set (defaults to all features) - """ - self.outcome_results = outcome_results - self.correlation_matrix = correlation_matrix - - # Initialize feature sets - if initial_features is not None: - self.initial_features = set(initial_features) - else: - self.initial_features = set(outcome_results.features) - - self.selected_features = self.initial_features.copy() - self.removed_features: set[str] = set() - self.selection_steps: list[SelectionStep] = [] - - def filter_by_ic( - self, - threshold: float, - min_periods: int = 1, - lag: int | None = None, - ) -> FeatureSelector: - """Filter features by Information Coefficient. - - Keeps features with |IC| > threshold. IC measures the predictive power - of a feature for the outcome variable. - - Parameters - ---------- - threshold : float - Minimum absolute IC value to keep a feature. - Typical values: 0.01-0.05 (1-5% correlation) - min_periods : int, default 1 - Minimum number of observations required for IC calculation - lag : int | None, default None - Specific forward lag to use for filtering. - If None, uses mean IC across all lags. - - Returns - ------- - self : FeatureSelector - Returns self for method chaining - - Examples - -------- - >>> selector.filter_by_ic(threshold=0.02, min_periods=20) - >>> # Keep only features with |IC| > 0.02 - """ - features_before = len(self.selected_features) - features_to_remove = [] - - for feature in self.selected_features: - if feature not in self.outcome_results.ic_results: - continue - - ic_result = self.outcome_results.ic_results[feature] - - # Check min_periods - if ic_result.n_observations < min_periods: - features_to_remove.append(feature) - continue - - # Get IC value - if lag is not None: - # Use specific lag - if lag not in ic_result.ic_by_lag: - features_to_remove.append(feature) - continue - ic_value = abs(ic_result.ic_by_lag[lag]) - else: - # Use mean IC - ic_value = abs(ic_result.ic_mean) - - # Filter by threshold - if ic_value < threshold: - features_to_remove.append(feature) - - # Update selected features - self.selected_features -= set(features_to_remove) - self.removed_features |= set(features_to_remove) - - # Record step - step = SelectionStep( - step_name="IC Filtering", - parameters={ - "threshold": threshold, - "min_periods": min_periods, - "lag": lag, - }, - features_before=features_before, - features_after=len(self.selected_features), - features_removed=features_to_remove, - features_kept=list(self.selected_features), - reasoning=f"Removed features with |IC| < {threshold}", - ) - self.selection_steps.append(step) - - return self - - def filter_by_importance( - self, - threshold: float, - method: Literal["mdi", "permutation", "shap"] = "mdi", - top_k: int | None = None, - ) -> FeatureSelector: - """Filter features by ML importance scores. - - Keeps features with importance > threshold or top K most important features. - - Parameters - ---------- - threshold : float - Minimum importance value to keep a feature. - Set to 0 if using top_k instead. - method : {"mdi", "permutation", "shap"}, default "mdi" - Importance method to use: - - "mdi": Mean Decrease in Impurity (tree-based) - - "permutation": Permutation importance - - "shap": SHAP values (if available) - top_k : int | None, default None - If provided, keeps only the top K most important features - regardless of threshold. - - Returns - ------- - self : FeatureSelector - Returns self for method chaining - - Examples - -------- - >>> # Threshold-based filtering - >>> selector.filter_by_importance(threshold=0.01, method="mdi") - >>> - >>> # Top-K filtering - >>> selector.filter_by_importance(threshold=0, method="shap", top_k=20) - """ - features_before = len(self.selected_features) - - # Get importance values for all selected features - feature_importance = [] - for feature in self.selected_features: - if feature not in self.outcome_results.importance_results: - continue - - imp_result = self.outcome_results.importance_results[feature] - - # Get importance based on method - if method == "mdi": - importance = imp_result.mdi_importance - elif method == "permutation": - importance = imp_result.permutation_importance - elif method == "shap": - if imp_result.shap_mean is None: - continue # Skip features without SHAP - importance = imp_result.shap_mean - else: - raise ValueError( - f"Unknown importance method: {method}. Choose from 'mdi', 'permutation', 'shap'" - ) - - feature_importance.append((feature, importance)) - - # Sort by importance (descending) - feature_importance.sort(key=lambda x: x[1], reverse=True) - - # Determine features to keep - if top_k is not None: - # Keep top K - features_to_keep = [f for f, _ in feature_importance[:top_k]] - reasoning = f"Kept top {top_k} features by {method} importance" - else: - # Keep features above threshold - features_to_keep = [f for f, imp in feature_importance if imp >= threshold] - reasoning = f"Removed features with {method} importance < {threshold}" - - # Update selected features - features_to_remove = [f for f in self.selected_features if f not in features_to_keep] - self.selected_features = set(features_to_keep) - self.removed_features |= set(features_to_remove) - - # Record step - step = SelectionStep( - step_name=f"Importance Filtering ({method.upper()})", - parameters={ - "threshold": threshold, - "method": method, - "top_k": top_k, - }, - features_before=features_before, - features_after=len(self.selected_features), - features_removed=features_to_remove, - features_kept=list(self.selected_features), - reasoning=reasoning, - ) - self.selection_steps.append(step) - - return self - - def filter_by_correlation( - self, - threshold: float, - keep_strategy: Literal["higher_ic", "higher_importance", "first"] = "higher_ic", - ) -> FeatureSelector: - """Remove highly correlated features to reduce redundancy. - - When two features have correlation > threshold, keeps one based on - the keep_strategy. - - Parameters - ---------- - threshold : float - Maximum absolute correlation allowed between features. - Typical values: 0.7-0.9 - keep_strategy : {"higher_ic", "higher_importance", "first"}, default "higher_ic" - Strategy for choosing which feature to keep: - - "higher_ic": Keep feature with higher |IC| - - "higher_importance": Keep feature with higher MDI importance - - "first": Keep feature that appears first alphabetically - - Returns - ------- - self : FeatureSelector - Returns self for method chaining - - Raises - ------ - ValueError - If correlation_matrix was not provided during initialization - - Examples - -------- - >>> selector.filter_by_correlation(threshold=0.8, keep_strategy="higher_ic") - >>> # Removes one feature from each pair with correlation > 0.8 - """ - if self.correlation_matrix is None: - raise ValueError( - "Correlation matrix required for correlation filtering. " - "Provide correlation_matrix during FeatureSelector initialization." - ) - - features_before = len(self.selected_features) - features_to_remove = set() - - # Convert to pandas for easier manipulation - - # Check if 'feature' column exists (indexed format) - if "feature" in self.correlation_matrix.columns: - # Convert from Polars with 'feature' column to pandas with index - corr_df = self.correlation_matrix.to_pandas() - corr_df = corr_df.set_index("feature") - else: - # Already in proper format - corr_df = self.correlation_matrix.to_pandas() - - # Filter correlation matrix to selected features only - selected_list = sorted(self.selected_features) - if not all(f in corr_df.index for f in selected_list): - # Some features missing from correlation matrix - skip them - selected_list = [f for f in selected_list if f in corr_df.index] - - if len(selected_list) < 2: - # Not enough features to check correlation - step = SelectionStep( - step_name="Correlation Filtering", - parameters={"threshold": threshold, "keep_strategy": keep_strategy}, - features_before=features_before, - features_after=features_before, - features_removed=[], - features_kept=list(self.selected_features), - reasoning="Insufficient features for correlation filtering", - ) - self.selection_steps.append(step) - return self - - corr_subset = corr_df.loc[selected_list, selected_list] - - # Find pairs above threshold - for i, feat1 in enumerate(selected_list): - if feat1 in features_to_remove: - continue - - for feat2 in selected_list[i + 1 :]: - if feat2 in features_to_remove: - continue - - corr_value = abs(corr_subset.loc[feat1, feat2]) - - if corr_value > threshold: - # Decide which feature to remove - if keep_strategy == "higher_ic": - ic1 = abs( - self.outcome_results.ic_results.get( - feat1, type("", (), {"ic_mean": 0})() - ).ic_mean - ) - ic2 = abs( - self.outcome_results.ic_results.get( - feat2, type("", (), {"ic_mean": 0})() - ).ic_mean - ) - to_remove = feat2 if ic1 > ic2 else feat1 - - elif keep_strategy == "higher_importance": - imp1 = self.outcome_results.importance_results.get( - feat1, type("", (), {"mdi_importance": 0})() - ).mdi_importance - imp2 = self.outcome_results.importance_results.get( - feat2, type("", (), {"mdi_importance": 0})() - ).mdi_importance - to_remove = feat2 if imp1 > imp2 else feat1 - - else: # "first" - to_remove = feat2 # Keep first alphabetically - - features_to_remove.add(to_remove) - - # Update selected features - self.selected_features -= features_to_remove - self.removed_features |= features_to_remove - - # Record step - step = SelectionStep( - step_name="Correlation Filtering", - parameters={"threshold": threshold, "keep_strategy": keep_strategy}, - features_before=features_before, - features_after=len(self.selected_features), - features_removed=list(features_to_remove), - features_kept=list(self.selected_features), - reasoning=f"Removed features with correlation > {threshold} using {keep_strategy} strategy", - ) - self.selection_steps.append(step) - - return self - - def filter_by_drift( - self, - threshold: float = 0.2, - method: Literal["psi", "consensus"] = "psi", - ) -> FeatureSelector: - """Remove features with unstable distributions (drift). - - Features with distribution drift may not generalize well to new data. - - Parameters - ---------- - threshold : float, default 0.2 - Drift threshold: - - For PSI: PSI >= 0.2 indicates significant drift - - For consensus: drift_probability >= threshold - method : {"psi", "consensus"}, default "psi" - Drift detection method: - - "psi": Use PSI alert level (red = drifted) - - "consensus": Use consensus probability from multiple methods - - Returns - ------- - self : FeatureSelector - Returns self for method chaining - - Raises - ------ - ValueError - If drift_results not available in outcome_results - - Examples - -------- - >>> selector.filter_by_drift(threshold=0.2, method="psi") - >>> # Removes features with PSI >= 0.2 (red alert) - """ - if self.outcome_results.drift_results is None: - raise ValueError( - "Drift results not available. Run outcome analysis with drift_detection=True." - ) - - features_before = len(self.selected_features) - features_to_remove = [] - - drift_results = self.outcome_results.drift_results - - for feature_result in drift_results.feature_results: - feature = feature_result.feature - - if feature not in self.selected_features: - continue - - # Determine if feature has drifted - if method == "psi": - # Use PSI alert level - red alert indicates drift - if ( - feature_result.psi_result is not None - and feature_result.psi_result.alert_level == "red" - ): - features_to_remove.append(feature) - - elif method == "consensus": - # Use consensus drift probability - if feature_result.drift_probability >= threshold: - features_to_remove.append(feature) - - else: - raise ValueError(f"Unknown drift method: {method}. Choose from 'psi', 'consensus'") - - # Update selected features - self.selected_features -= set(features_to_remove) - self.removed_features |= set(features_to_remove) - - # Record step - step = SelectionStep( - step_name="Drift Filtering", - parameters={"threshold": threshold, "method": method}, - features_before=features_before, - features_after=len(self.selected_features), - features_removed=features_to_remove, - features_kept=list(self.selected_features), - reasoning=f"Removed features with {method} drift >= {threshold}", - ) - self.selection_steps.append(step) - - return self - - def run_pipeline( - self, - steps: list[tuple[str, dict[str, Any]]], - ) -> FeatureSelector: - """Execute multiple selection filters in sequence. - - Provides a convenient way to run a complete selection pipeline - with multiple filtering criteria. - - Parameters - ---------- - steps : list[tuple[str, dict]] - List of (filter_name, parameters) tuples. - Valid filter names: - - "ic": filter_by_ic() - - "importance": filter_by_importance() - - "correlation": filter_by_correlation() - - "drift": filter_by_drift() - - Returns - ------- - self : FeatureSelector - Returns self for method chaining - - Examples - -------- - >>> selector.run_pipeline([ - ... ("ic", {"threshold": 0.02, "min_periods": 20}), - ... ("correlation", {"threshold": 0.8}), - ... ("importance", {"threshold": 0.01, "method": "mdi"}), - ... ("drift", {"threshold": 0.2}) - ... ]) - >>> print(selector.get_selection_report().summary()) - """ - for filter_name, params in steps: - if filter_name == "ic": - self.filter_by_ic(**params) - elif filter_name == "importance": - self.filter_by_importance(**params) - elif filter_name == "correlation": - self.filter_by_correlation(**params) - elif filter_name == "drift": - self.filter_by_drift(**params) - else: - raise ValueError( - f"Unknown filter: {filter_name}. " - "Valid filters: ic, importance, correlation, drift" - ) - - return self - - def get_selected_features(self) -> list[str]: - """Get current list of selected features. - - Returns - ------- - list[str] - Sorted list of selected feature names - """ - return sorted(self.selected_features) - - def get_removed_features(self) -> list[str]: - """Get list of features that were removed. - - Returns - ------- - list[str] - Sorted list of removed feature names - """ - return sorted(self.removed_features) - - def get_selection_report(self) -> SelectionReport: - """Generate comprehensive selection report. - - Returns - ------- - SelectionReport - Report with selection steps and final features - """ - return SelectionReport( - initial_features=sorted(self.initial_features), - final_features=self.get_selected_features(), - steps=self.selection_steps, - ) - - def reset(self) -> FeatureSelector: - """Reset selector to initial feature set. - - Clears all filters and selection steps. - - Returns - ------- - self : FeatureSelector - Returns self for method chaining - """ - self.selected_features = self.initial_features.copy() - self.removed_features = set() - self.selection_steps = [] - return self diff --git a/src/ml4t/engineer/store/offline.py b/src/ml4t/engineer/store/offline.py index 063bf08..e15c531 100644 --- a/src/ml4t/engineer/store/offline.py +++ b/src/ml4t/engineer/store/offline.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="arg-type,call-arg,assignment,return-value" """Offline feature store using DuckDB with Arrow integration. Exports: diff --git a/src/ml4t/engineer/utils/dependencies.py b/src/ml4t/engineer/utils/dependencies.py index 4d8a050..7389c44 100644 --- a/src/ml4t/engineer/utils/dependencies.py +++ b/src/ml4t/engineer/utils/dependencies.py @@ -1,4 +1,3 @@ -# mypy: disable-error-code="assignment,no-untyped-def,no-untyped-call,no-any-return" # ruff: noqa: UP006, UP045, B007 """Optional dependency checking and validation utilities. diff --git a/src/ml4t/engineer/visualization/__init__.py b/src/ml4t/engineer/visualization/__init__.py index 2a5bc58..84886ee 100644 --- a/src/ml4t/engineer/visualization/__init__.py +++ b/src/ml4t/engineer/visualization/__init__.py @@ -1,34 +1,16 @@ -"""Unified visualization for feature analysis. +"""Plot export utilities. -.. deprecated:: 0.1.0a8 - Feature outcome visualizations have moved to ``ml4t-diagnostic``. +For feature analysis visualizations (IC, importance, drift), +use ``ml4t.diagnostic.visualization``. -This module provides basic plot export utilities. For feature analysis -visualizations (importance, IC, drift), use ``ml4t.diagnostic.visualization``. - -Key Functions -------------- -export_plot : Export figure to PNG/PDF with quality control -plot_feature_analysis_summary : DEPRECATED - use ml4t.diagnostic - -Examples --------- ->>> # For feature analysis visualizations, use ml4t-diagnostic: ->>> from ml4t.diagnostic.evaluation import FeatureOutcome ->>> from ml4t.diagnostic.visualization import plot_importance_summary ->>> ->>> analyzer = FeatureOutcome() ->>> results = analyzer.run_analysis(features_df, outcomes_df) ->>> fig = plot_importance_summary(results) ->>> ->>> # Use export_plot for saving any matplotlib figure: +Example +------- >>> from ml4t.engineer.visualization import export_plot >>> export_plot(fig, "analysis.png", dpi=300) """ -from ml4t.engineer.visualization.summary import export_plot, plot_feature_analysis_summary +from ml4t.engineer.visualization.summary import export_plot __all__ = [ - "plot_feature_analysis_summary", "export_plot", ] diff --git a/src/ml4t/engineer/visualization/summary.py b/src/ml4t/engineer/visualization/summary.py index fe29576..b3f0ec7 100644 --- a/src/ml4t/engineer/visualization/summary.py +++ b/src/ml4t/engineer/visualization/summary.py @@ -1,19 +1,7 @@ -"""Unified feature analysis visualization. +"""Plot export utilities. -.. deprecated:: 0.1.0a8 - The feature outcome visualization has moved to ``ml4t-diagnostic``. - Use ``ml4t.diagnostic.visualization`` for IC plots, importance plots, - and feature analysis summaries. - -This module now provides only basic export utilities. For feature analysis -visualizations, use:: - - from ml4t.diagnostic.visualization import ( - plot_importance_summary, - plot_importance_bar, - plot_importance_heatmap, - ) - from ml4t.diagnostic.visualization.feature_plots import plot_importance_distribution +For feature analysis visualizations (IC, importance, drift), +use ``ml4t.diagnostic.visualization``. """ from __future__ import annotations @@ -25,39 +13,6 @@ from matplotlib.figure import Figure -def plot_feature_analysis_summary(*args: Any, **kwargs: Any) -> Any: - """Create unified 3-panel feature analysis summary. - - .. deprecated:: 0.1.0a8 - This function has been moved to ``ml4t-diagnostic``. - - Use the following instead:: - - from ml4t.diagnostic.visualization import plot_importance_summary - from ml4t.diagnostic.evaluation import FeatureOutcome - - analyzer = FeatureOutcome() - results = analyzer.run_analysis(features_df, outcomes_df) - fig = plot_importance_summary(results) - - Raises - ------ - NotImplementedError - Always. Directs users to ml4t.diagnostic. - """ - msg = ( - "plot_feature_analysis_summary has been moved to ml4t-diagnostic. " - "Install with: pip install ml4t-diagnostic\n\n" - "Usage:\n" - " from ml4t.diagnostic.visualization import plot_importance_summary\n" - " from ml4t.diagnostic.evaluation import FeatureOutcome\n\n" - " analyzer = FeatureOutcome()\n" - " results = analyzer.run_analysis(features_df, outcomes_df)\n" - " fig = plot_importance_summary(results)" - ) - raise NotImplementedError(msg) - - def export_plot( fig: Figure, output_path: str | Path, diff --git a/tests/labeling/test_labeling_coverage.py b/tests/labeling/test_labeling_coverage.py index 537ac40..52df458 100644 --- a/tests/labeling/test_labeling_coverage.py +++ b/tests/labeling/test_labeling_coverage.py @@ -335,17 +335,18 @@ def test_trailing_stop_true_uses_lower_barrier(self, sample_data): assert "label" in result.columns def test_trailing_stop_true_no_lower_barrier(self, sample_data): - """Test that trailing_stop=True with no lower_barrier uses default.""" + """Test that trailing_stop=True with no lower_barrier raises error.""" + from ml4t.engineer.core.exceptions import DataValidationError + config = LabelingConfig.triple_barrier( upper_barrier=0.05, lower_barrier=None, max_holding_period=20, - trailing_stop=True, # Should use default 1% + trailing_stop=True, ) - result = triple_barrier_labels(sample_data, config, price_col="close") - - assert "label" in result.columns + with pytest.raises(DataValidationError, match="trailing_stop=True requires"): + triple_barrier_labels(sample_data, config, price_col="close") class TestShortPositionLabeling: diff --git a/tests/selection/__init__.py b/tests/selection/__init__.py deleted file mode 100644 index 74bf738..0000000 --- a/tests/selection/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for feature selection module.""" diff --git a/tests/selection/test_systematic.py b/tests/selection/test_systematic.py deleted file mode 100644 index 01a2c8e..0000000 --- a/tests/selection/test_systematic.py +++ /dev/null @@ -1,786 +0,0 @@ -"""Tests for systematic feature selection. - -These tests require ml4t-diagnostic to be installed for the test fixtures. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import numpy as np -import polars as pl -import pytest - -# Feature selection tests require ml4t-diagnostic for the outcome data classes -try: - from ml4t.diagnostic.evaluation.drift import ( - DriftSummaryResult, - FeatureDriftResult, - PSIResult, - ) - from ml4t.diagnostic.evaluation.feature_outcome import ( - FeatureICResults, - FeatureImportanceResults, - FeatureOutcomeResult, - ) - - HAS_DIAGNOSTIC = True -except ImportError: - HAS_DIAGNOSTIC = False - -from ml4t.engineer.selection import FeatureSelector -from ml4t.engineer.selection.systematic import SelectionReport, SelectionStep - -if TYPE_CHECKING: - pass - -pytestmark = pytest.mark.skipif( - not HAS_DIAGNOSTIC, reason="ml4t-diagnostic required for feature selection tests" -) - - -@pytest.fixture -def sample_outcome_results() -> FeatureOutcomeResult: - """Create sample feature-outcome results for testing.""" - features = ["feature_a", "feature_b", "feature_c", "feature_d", "feature_e"] - - # IC results with varying quality - ic_results = { - "feature_a": FeatureICResults( - feature="feature_a", - ic_mean=0.05, # Strong IC - ic_std=0.01, - ic_ir=5.0, - t_stat=10.0, - p_value=0.001, - ic_by_lag={0: 0.04, 1: 0.05, 5: 0.06}, - n_observations=100, - ), - "feature_b": FeatureICResults( - feature="feature_b", - ic_mean=0.03, # Good IC - ic_std=0.01, - ic_ir=3.0, - t_stat=6.0, - p_value=0.01, - ic_by_lag={0: 0.02, 1: 0.03, 5: 0.04}, - n_observations=100, - ), - "feature_c": FeatureICResults( - feature="feature_c", - ic_mean=0.01, # Weak IC - ic_std=0.01, - ic_ir=1.0, - t_stat=2.0, - p_value=0.05, - ic_by_lag={0: 0.005, 1: 0.01, 5: 0.015}, - n_observations=100, - ), - "feature_d": FeatureICResults( - feature="feature_d", - ic_mean=0.025, # Medium IC - ic_std=0.01, - ic_ir=2.5, - t_stat=5.0, - p_value=0.02, - ic_by_lag={0: 0.02, 1: 0.025, 5: 0.03}, - n_observations=100, - ), - "feature_e": FeatureICResults( - feature="feature_e", - ic_mean=0.002, # Very weak IC - ic_std=0.01, - ic_ir=0.2, - t_stat=0.4, - p_value=0.7, - ic_by_lag={0: 0.001, 1: 0.002, 5: 0.003}, - n_observations=100, - ), - } - - # Importance results - importance_results = { - "feature_a": FeatureImportanceResults( - feature="feature_a", - mdi_importance=0.30, # High importance - permutation_importance=0.25, - permutation_std=0.02, - shap_mean=0.28, - shap_std=0.03, - rank_mdi=1, - rank_permutation=1, - ), - "feature_b": FeatureImportanceResults( - feature="feature_b", - mdi_importance=0.25, # Good importance - permutation_importance=0.22, - permutation_std=0.02, - shap_mean=0.23, - shap_std=0.03, - rank_mdi=2, - rank_permutation=2, - ), - "feature_c": FeatureImportanceResults( - feature="feature_c", - mdi_importance=0.05, # Low importance - permutation_importance=0.03, - permutation_std=0.01, - shap_mean=0.04, - shap_std=0.02, - rank_mdi=5, - rank_permutation=5, - ), - "feature_d": FeatureImportanceResults( - feature="feature_d", - mdi_importance=0.20, # Medium importance - permutation_importance=0.18, - permutation_std=0.02, - shap_mean=0.19, - shap_std=0.03, - rank_mdi=3, - rank_permutation=3, - ), - "feature_e": FeatureImportanceResults( - feature="feature_e", - mdi_importance=0.15, # Medium-low importance - permutation_importance=0.12, - permutation_std=0.02, - shap_mean=0.13, - shap_std=0.02, - rank_mdi=4, - rank_permutation=4, - ), - } - - # Drift results - drift_feature_results = [ - FeatureDriftResult( - feature="feature_a", - psi_result=PSIResult( - psi=0.05, # No drift - bin_psi=np.array([0.01, 0.01, 0.01, 0.01, 0.01]), - bin_edges=np.array([0.0, 0.2, 0.4, 0.6, 0.8, 1.0]), - reference_counts=np.array([20, 20, 20, 20, 20]), - test_counts=np.array([19, 21, 20, 20, 20]), - reference_percents=np.array([0.2, 0.2, 0.2, 0.2, 0.2]), - test_percents=np.array([0.19, 0.21, 0.2, 0.2, 0.2]), - n_bins=5, - is_categorical=False, - alert_level="green", - interpretation="No drift", - ), - drifted=False, - n_methods_run=1, - n_methods_detected=0, - drift_probability=0.0, - ), - FeatureDriftResult( - feature="feature_b", - psi_result=PSIResult( - psi=0.15, # Small drift - bin_psi=np.array([0.03, 0.03, 0.03, 0.03, 0.03]), - bin_edges=np.array([0.0, 0.2, 0.4, 0.6, 0.8, 1.0]), - reference_counts=np.array([20, 20, 20, 20, 20]), - test_counts=np.array([15, 25, 20, 20, 20]), - reference_percents=np.array([0.2, 0.2, 0.2, 0.2, 0.2]), - test_percents=np.array([0.15, 0.25, 0.2, 0.2, 0.2]), - n_bins=5, - is_categorical=False, - alert_level="yellow", - interpretation="Small drift", - ), - drifted=False, - n_methods_run=1, - n_methods_detected=0, - drift_probability=0.0, - ), - FeatureDriftResult( - feature="feature_c", - psi_result=PSIResult( - psi=0.25, # Significant drift - bin_psi=np.array([0.05, 0.05, 0.05, 0.05, 0.05]), - bin_edges=np.array([0.0, 0.2, 0.4, 0.6, 0.8, 1.0]), - reference_counts=np.array([20, 20, 20, 20, 20]), - test_counts=np.array([10, 30, 20, 20, 20]), - reference_percents=np.array([0.2, 0.2, 0.2, 0.2, 0.2]), - test_percents=np.array([0.1, 0.3, 0.2, 0.2, 0.2]), - n_bins=5, - is_categorical=False, - alert_level="red", - interpretation="Significant drift", - ), - drifted=True, - n_methods_run=1, - n_methods_detected=1, - drift_probability=1.0, - ), - FeatureDriftResult( - feature="feature_d", - psi_result=PSIResult( - psi=0.08, # No drift - bin_psi=np.array([0.016, 0.016, 0.016, 0.016, 0.016]), - bin_edges=np.array([0.0, 0.2, 0.4, 0.6, 0.8, 1.0]), - reference_counts=np.array([20, 20, 20, 20, 20]), - test_counts=np.array([18, 22, 20, 20, 20]), - reference_percents=np.array([0.2, 0.2, 0.2, 0.2, 0.2]), - test_percents=np.array([0.18, 0.22, 0.2, 0.2, 0.2]), - n_bins=5, - is_categorical=False, - alert_level="green", - interpretation="No drift", - ), - drifted=False, - n_methods_run=1, - n_methods_detected=0, - drift_probability=0.0, - ), - FeatureDriftResult( - feature="feature_e", - psi_result=PSIResult( - psi=0.30, # Strong drift - bin_psi=np.array([0.06, 0.06, 0.06, 0.06, 0.06]), - bin_edges=np.array([0.0, 0.2, 0.4, 0.6, 0.8, 1.0]), - reference_counts=np.array([20, 20, 20, 20, 20]), - test_counts=np.array([5, 35, 20, 20, 20]), - reference_percents=np.array([0.2, 0.2, 0.2, 0.2, 0.2]), - test_percents=np.array([0.05, 0.35, 0.2, 0.2, 0.2]), - n_bins=5, - is_categorical=False, - alert_level="red", - interpretation="Strong drift", - ), - drifted=True, - n_methods_run=1, - n_methods_detected=1, - drift_probability=1.0, - ), - ] - - drift_results = DriftSummaryResult( - feature_results=drift_feature_results, - n_features=5, - n_features_drifted=2, - drifted_features=["feature_c", "feature_e"], - overall_drifted=True, - ) - - return FeatureOutcomeResult( - features=features, - ic_results=ic_results, - importance_results=importance_results, - drift_results=drift_results, - ) - - -@pytest.fixture -def sample_correlation_matrix() -> pl.DataFrame: - """Create sample correlation matrix with some highly correlated features.""" - # feature_b and feature_d are highly correlated (0.85) - # Others have moderate correlation - # Create as polars DataFrame directly - corr_data = { - "feature": ["feature_a", "feature_b", "feature_c", "feature_d", "feature_e"], - "feature_a": [1.0, 0.3, 0.2, 0.25, 0.1], - "feature_b": [0.3, 1.0, 0.4, 0.85, 0.35], # High correlation with feature_d - "feature_c": [0.2, 0.4, 1.0, 0.45, 0.5], - "feature_d": [0.25, 0.85, 0.45, 1.0, 0.4], # High correlation with feature_b - "feature_e": [0.1, 0.35, 0.5, 0.4, 1.0], - } - return pl.DataFrame(corr_data) - - -class TestFeatureSelector: - """Test suite for FeatureSelector class.""" - - def test_initialization( - self, sample_outcome_results: FeatureOutcomeResult, sample_correlation_matrix: pl.DataFrame - ): - """Test selector initialization.""" - selector = FeatureSelector(sample_outcome_results, sample_correlation_matrix) - - assert len(selector.initial_features) == 5 - assert len(selector.selected_features) == 5 - assert len(selector.removed_features) == 0 - assert len(selector.selection_steps) == 0 - - def test_initialization_with_custom_features( - self, sample_outcome_results: FeatureOutcomeResult - ): - """Test initialization with custom feature list.""" - initial = ["feature_a", "feature_b", "feature_c"] - selector = FeatureSelector(sample_outcome_results, initial_features=initial) - - assert len(selector.initial_features) == 3 - assert len(selector.selected_features) == 3 - - def test_filter_by_ic_threshold(self, sample_outcome_results: FeatureOutcomeResult): - """Test IC filtering with threshold.""" - selector = FeatureSelector(sample_outcome_results) - - # Filter with threshold 0.02 (should keep a, b, d) - selector.filter_by_ic(threshold=0.02) - - selected = selector.get_selected_features() - removed = selector.get_removed_features() - - # feature_a (0.05), feature_b (0.03), feature_d (0.025) should pass - # feature_c (0.01), feature_e (0.002) should be removed - assert "feature_a" in selected - assert "feature_b" in selected - assert "feature_d" in selected - assert "feature_c" in removed - assert "feature_e" in removed - - def test_filter_by_ic_with_lag(self, sample_outcome_results: FeatureOutcomeResult): - """Test IC filtering with specific lag.""" - selector = FeatureSelector(sample_outcome_results) - - # Filter using lag=5 with threshold 0.03 - selector.filter_by_ic(threshold=0.03, lag=5) - - selected = selector.get_selected_features() - - # At lag=5: feature_a (0.06), feature_b (0.04), feature_d (0.03) should pass - assert "feature_a" in selected - assert "feature_b" in selected - assert "feature_d" in selected - - def test_filter_by_ic_min_periods(self, sample_outcome_results: FeatureOutcomeResult): - """Test IC filtering respects min_periods.""" - # Modify one feature to have insufficient observations - sample_outcome_results.ic_results["feature_a"].n_observations = 10 - - selector = FeatureSelector(sample_outcome_results) - selector.filter_by_ic(threshold=0.01, min_periods=20) - - removed = selector.get_removed_features() - - # feature_a should be removed due to insufficient observations - assert "feature_a" in removed - - def test_filter_by_importance_mdi(self, sample_outcome_results: FeatureOutcomeResult): - """Test importance filtering with MDI.""" - selector = FeatureSelector(sample_outcome_results) - - # Filter with MDI threshold 0.15 (should keep a, b, d, e) - selector.filter_by_importance(threshold=0.15, method="mdi") - - selected = selector.get_selected_features() - removed = selector.get_removed_features() - - # feature_c (0.05) should be removed - assert "feature_c" in removed - assert len(selected) == 4 - - def test_filter_by_importance_permutation(self, sample_outcome_results: FeatureOutcomeResult): - """Test importance filtering with permutation.""" - selector = FeatureSelector(sample_outcome_results) - - selector.filter_by_importance(threshold=0.15, method="permutation") - - selected = selector.get_selected_features() - - # Based on permutation_importance values - assert "feature_a" in selected # 0.25 - assert "feature_b" in selected # 0.22 - assert "feature_d" in selected # 0.18 - - def test_filter_by_importance_shap(self, sample_outcome_results: FeatureOutcomeResult): - """Test importance filtering with SHAP.""" - selector = FeatureSelector(sample_outcome_results) - - selector.filter_by_importance(threshold=0.15, method="shap") - - selected = selector.get_selected_features() - - # Based on shap_mean values - assert "feature_a" in selected # 0.28 - assert "feature_b" in selected # 0.23 - assert "feature_d" in selected # 0.19 - - def test_filter_by_importance_top_k(self, sample_outcome_results: FeatureOutcomeResult): - """Test importance filtering with top-K selection.""" - selector = FeatureSelector(sample_outcome_results) - - # Keep only top 3 features - selector.filter_by_importance(threshold=0, method="mdi", top_k=3) - - selected = selector.get_selected_features() - - assert len(selected) == 3 - # Should keep feature_a, feature_b, feature_d (highest MDI) - assert "feature_a" in selected - assert "feature_b" in selected - assert "feature_d" in selected - - def test_filter_by_correlation( - self, sample_outcome_results: FeatureOutcomeResult, sample_correlation_matrix: pl.DataFrame - ): - """Test correlation filtering.""" - selector = FeatureSelector(sample_outcome_results, sample_correlation_matrix) - - # Filter with threshold 0.8 (should remove one of feature_b/feature_d) - selector.filter_by_correlation(threshold=0.8, keep_strategy="higher_ic") - - selected = selector.get_selected_features() - removed = selector.get_removed_features() - - # feature_b and feature_d have correlation 0.85 - # feature_b has IC 0.03, feature_d has IC 0.025 - # Should keep feature_b (higher IC) and remove feature_d - assert "feature_b" in selected - assert "feature_d" in removed - - def test_filter_by_correlation_higher_importance( - self, sample_outcome_results: FeatureOutcomeResult, sample_correlation_matrix: pl.DataFrame - ): - """Test correlation filtering with importance strategy.""" - selector = FeatureSelector(sample_outcome_results, sample_correlation_matrix) - - selector.filter_by_correlation(threshold=0.8, keep_strategy="higher_importance") - - selected = selector.get_selected_features() - removed = selector.get_removed_features() - - # feature_b has MDI 0.25, feature_d has MDI 0.20 - # Should keep feature_b and remove feature_d - assert "feature_b" in selected - assert "feature_d" in removed - - def test_filter_by_correlation_first_strategy( - self, sample_outcome_results: FeatureOutcomeResult, sample_correlation_matrix: pl.DataFrame - ): - """Test correlation filtering with first strategy.""" - selector = FeatureSelector(sample_outcome_results, sample_correlation_matrix) - - selector.filter_by_correlation(threshold=0.8, keep_strategy="first") - - selected = selector.get_selected_features() - removed = selector.get_removed_features() - - # Should keep feature_b (alphabetically first) and remove feature_d - assert "feature_b" in selected - assert "feature_d" in removed - - def test_filter_by_correlation_no_matrix(self, sample_outcome_results: FeatureOutcomeResult): - """Test correlation filtering raises error without correlation matrix.""" - selector = FeatureSelector(sample_outcome_results, correlation_matrix=None) - - with pytest.raises(ValueError, match="Correlation matrix required"): - selector.filter_by_correlation(threshold=0.8) - - def test_filter_by_drift_psi(self, sample_outcome_results: FeatureOutcomeResult): - """Test drift filtering with PSI method.""" - selector = FeatureSelector(sample_outcome_results) - - # Filter features with red alert (PSI >= 0.2) - selector.filter_by_drift(threshold=0.2, method="psi") - - selected = selector.get_selected_features() - removed = selector.get_removed_features() - - # feature_c and feature_e have red alert - assert "feature_c" in removed - assert "feature_e" in removed - assert "feature_a" in selected - assert "feature_b" in selected - assert "feature_d" in selected - - def test_filter_by_drift_consensus(self, sample_outcome_results: FeatureOutcomeResult): - """Test drift filtering with consensus method.""" - selector = FeatureSelector(sample_outcome_results) - - # Filter features with drift_probability >= 0.5 - selector.filter_by_drift(threshold=0.5, method="consensus") - - removed = selector.get_removed_features() - - # feature_c and feature_e have drift_probability = 1.0 - assert "feature_c" in removed - assert "feature_e" in removed - - def test_filter_by_drift_no_results(self, sample_outcome_results: FeatureOutcomeResult): - """Test drift filtering raises error without drift results.""" - # Remove drift results - sample_outcome_results.drift_results = None - - selector = FeatureSelector(sample_outcome_results) - - with pytest.raises(ValueError, match="Drift results not available"): - selector.filter_by_drift(threshold=0.2) - - def test_run_pipeline( - self, sample_outcome_results: FeatureOutcomeResult, sample_correlation_matrix: pl.DataFrame - ): - """Test running complete pipeline.""" - selector = FeatureSelector(sample_outcome_results, sample_correlation_matrix) - - selector.run_pipeline( - [ - ("ic", {"threshold": 0.02, "min_periods": 20}), - ("correlation", {"threshold": 0.8}), - ("importance", {"threshold": 0.15, "method": "mdi"}), - ] - ) - - selected = selector.get_selected_features() - - # After IC filter: a, b, d remain (c, e removed) - # After correlation filter: a, b remain (d removed due to correlation with b) - # After importance filter: a, b remain (both > 0.15 MDI) - assert len(selected) == 2 - assert "feature_a" in selected - assert "feature_b" in selected - - def test_run_pipeline_with_drift( - self, sample_outcome_results: FeatureOutcomeResult, sample_correlation_matrix: pl.DataFrame - ): - """Test pipeline including drift filtering.""" - selector = FeatureSelector(sample_outcome_results, sample_correlation_matrix) - - selector.run_pipeline( - [ - ("drift", {"threshold": 0.2, "method": "psi"}), - ("ic", {"threshold": 0.02}), - ("importance", {"threshold": 0.15, "method": "mdi"}), - ] - ) - - selected = selector.get_selected_features() - - # After drift: a, b, d remain (c, e removed) - # After IC: a, b, d remain - # After importance: a, b, d remain (all > 0.15) - assert "feature_a" in selected - assert "feature_b" in selected - assert "feature_d" in selected - - def test_run_pipeline_invalid_filter(self, sample_outcome_results: FeatureOutcomeResult): - """Test pipeline raises error for invalid filter.""" - selector = FeatureSelector(sample_outcome_results) - - with pytest.raises(ValueError, match="Unknown filter"): - selector.run_pipeline([("invalid_filter", {})]) - - def test_method_chaining( - self, sample_outcome_results: FeatureOutcomeResult, sample_correlation_matrix: pl.DataFrame - ): - """Test method chaining for fluent API.""" - selector = FeatureSelector(sample_outcome_results, sample_correlation_matrix) - - # Chain multiple filters - result = ( - selector.filter_by_ic(threshold=0.02) - .filter_by_correlation(threshold=0.8) - .filter_by_importance(threshold=0.15, method="mdi") - ) - - # Should return self - assert result is selector - - selected = selector.get_selected_features() - assert len(selected) > 0 - - def test_get_selection_report( - self, sample_outcome_results: FeatureOutcomeResult, sample_correlation_matrix: pl.DataFrame - ): - """Test selection report generation.""" - selector = FeatureSelector(sample_outcome_results, sample_correlation_matrix) - - selector.filter_by_ic(threshold=0.02) - selector.filter_by_importance(threshold=0.15, method="mdi") - - report = selector.get_selection_report() - - assert len(report.initial_features) == 5 - assert len(report.final_features) < 5 - assert len(report.steps) == 2 - assert report.total_removed > 0 - assert report.removal_rate > 0 - - # Test summary - summary = report.summary() - assert "Feature Selection Report" in summary - assert "Initial Features:" in summary - assert "Final Features:" in summary - - def test_selection_step_summary(self, sample_outcome_results: FeatureOutcomeResult): - """Test selection step summary generation.""" - selector = FeatureSelector(sample_outcome_results) - - selector.filter_by_ic(threshold=0.02) - - step = selector.selection_steps[0] - summary = step.summary() - - assert "IC Filtering" in summary - assert "threshold" in summary - assert "removed" in summary.lower() - - def test_reset(self, sample_outcome_results: FeatureOutcomeResult): - """Test resetting selector to initial state.""" - selector = FeatureSelector(sample_outcome_results) - - # Apply some filters - selector.filter_by_ic(threshold=0.02) - selector.filter_by_importance(threshold=0.15, method="mdi") - - # Verify features were removed - assert len(selector.selected_features) < len(selector.initial_features) - assert len(selector.selection_steps) > 0 - - # Reset - selector.reset() - - # Verify reset - assert len(selector.selected_features) == len(selector.initial_features) - assert len(selector.removed_features) == 0 - assert len(selector.selection_steps) == 0 - - def test_edge_case_no_features_pass(self, sample_outcome_results: FeatureOutcomeResult): - """Test handling when no features pass filter.""" - selector = FeatureSelector(sample_outcome_results) - - # Very high threshold - no features should pass - selector.filter_by_ic(threshold=1.0) - - selected = selector.get_selected_features() - removed = selector.get_removed_features() - - assert len(selected) == 0 - assert len(removed) == 5 - - def test_edge_case_all_features_pass(self, sample_outcome_results: FeatureOutcomeResult): - """Test handling when all features pass filter.""" - selector = FeatureSelector(sample_outcome_results) - - # Very low threshold - all features should pass - selector.filter_by_ic(threshold=0.0) - - selected = selector.get_selected_features() - removed = selector.get_removed_features() - - assert len(selected) == 5 - assert len(removed) == 0 - - def test_edge_case_insufficient_features_for_correlation( - self, sample_outcome_results: FeatureOutcomeResult, sample_correlation_matrix: pl.DataFrame - ): - """Test correlation filtering with insufficient features.""" - # Start with only one feature - selector = FeatureSelector( - sample_outcome_results, - sample_correlation_matrix, - initial_features=["feature_a"], - ) - - # Should not crash - selector.filter_by_correlation(threshold=0.8) - - # Should have same feature - assert len(selector.selected_features) == 1 - assert "feature_a" in selector.selected_features - - -class TestSelectionReport: - """Test suite for SelectionReport class.""" - - def test_report_post_init(self): - """Test report post-initialization calculation.""" - report = SelectionReport( - initial_features=["a", "b", "c", "d", "e"], - final_features=["a", "b"], - ) - - assert report.total_removed == 3 - assert report.removal_rate == 60.0 - - def test_report_summary_formatting(self): - """Test report summary formatting.""" - step = SelectionStep( - step_name="Test Filter", - parameters={"threshold": 0.5}, - features_before=5, - features_after=3, - features_removed=["c", "d"], - features_kept=["a", "b", "e"], - reasoning="Test reasoning", - ) - - report = SelectionReport( - initial_features=["a", "b", "c", "d", "e"], - final_features=["a", "b", "e"], - steps=[step], - ) - - summary = report.summary() - - assert "Feature Selection Report" in summary - assert "Initial Features: 5" in summary - assert "Final Features: 3" in summary - assert "Removed: 2 (40.0%)" in summary - assert "Test Filter" in summary - assert "✓ a" in summary - assert "✓ b" in summary - assert "✓ e" in summary - - -class TestIntegration: - """Integration tests for complete workflows.""" - - def test_complete_selection_workflow( - self, sample_outcome_results: FeatureOutcomeResult, sample_correlation_matrix: pl.DataFrame - ): - """Test complete realistic selection workflow.""" - selector = FeatureSelector(sample_outcome_results, sample_correlation_matrix) - - # Realistic pipeline - selector.run_pipeline( - [ - # First remove drifted features - ("drift", {"threshold": 0.2, "method": "psi"}), - # Then filter by predictive power - ("ic", {"threshold": 0.02, "min_periods": 20}), - # Remove redundant features - ("correlation", {"threshold": 0.8, "keep_strategy": "higher_ic"}), - # Finally select most important features - ("importance", {"threshold": 0.15, "method": "mdi"}), - ] - ) - - selected = selector.get_selected_features() - report = selector.get_selection_report() - - # Verify we have a reasonable selection - assert len(selected) > 0 - assert len(selected) < len(sample_outcome_results.features) - - # Verify report has all steps - assert len(report.steps) == 4 - - # Print report for manual inspection - print("\n" + report.summary()) - - def test_top_k_selection_workflow( - self, sample_outcome_results: FeatureOutcomeResult, sample_correlation_matrix: pl.DataFrame - ): - """Test top-K feature selection workflow.""" - selector = FeatureSelector(sample_outcome_results, sample_correlation_matrix) - - # First remove problematic features, then select top K - selector.run_pipeline( - [ - ("drift", {"threshold": 0.2, "method": "psi"}), - ("correlation", {"threshold": 0.8}), - ("importance", {"threshold": 0, "method": "mdi", "top_k": 2}), - ] - ) - - selected = selector.get_selected_features() - - # Should have exactly 2 features - assert len(selected) == 2 - - # Should be the top 2 by MDI among non-drifted features - assert "feature_a" in selected - assert "feature_b" in selected diff --git a/tests/test_api.py b/tests/test_api.py index dafd191..60aac06 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -35,17 +35,20 @@ def sample_ohlcv_data(): def test_compute_single_feature_with_defaults(sample_ohlcv_data): - """Test computing a single feature with default parameters.""" - result = compute_features(sample_ohlcv_data, ["sma"]) + """Test computing a single feature using registry default parameters.""" + # RSI has parameters={"period": 14} in its @feature() registration + result = compute_features(sample_ohlcv_data, ["rsi"]) assert isinstance(result, pl.DataFrame) - # Check that sma column was added (column name format may vary) - assert "sma" in result.columns or "sma_20" in result.columns or "close_sma_20" in result.columns + assert "rsi" in result.columns def test_compute_multiple_features(sample_ohlcv_data): """Test computing multiple features.""" - features = ["sma", "ema"] + features = [ + {"name": "sma", "params": {"period": 20}}, + {"name": "ema", "params": {"period": 10}}, + ] result = compute_features(sample_ohlcv_data, features) assert isinstance(result, pl.DataFrame) @@ -68,7 +71,7 @@ def test_compute_feature_with_custom_params(sample_ohlcv_data): def test_compute_mixed_format(sample_ohlcv_data): """Test computing features with mixed default and custom params.""" features = [ - "sma", # Default parameters + "rsi", # Has parameters={"period": 14} in registration {"name": "ema", "params": {"period": 15}}, # Custom parameters ] result = compute_features(sample_ohlcv_data, features) @@ -83,7 +86,7 @@ def test_compute_mixed_format(sample_ohlcv_data): def test_compute_with_lazyframe(sample_ohlcv_data): """Test that API works with LazyFrame input.""" lazy_data = sample_ohlcv_data.lazy() - result = compute_features(lazy_data, ["sma"]) + result = compute_features(lazy_data, [{"name": "sma", "params": {"period": 20}}]) assert isinstance(result, pl.LazyFrame) # Collect to verify computation works @@ -158,10 +161,11 @@ def test_compute_from_yaml_config_simple_list(sample_ohlcv_data): """Test computing from YAML config with simple list format.""" pytest.importorskip("yaml") + # Use features that have default parameters in their registration with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: f.write(""" -- sma -- ema +- rsi +- macd """) config_path = f.name @@ -267,10 +271,17 @@ def test_duplicate_feature_names(sample_ohlcv_data): def test_features_with_empty_params(sample_ohlcv_data): - """Test features with explicit empty params dict.""" + """Test features with explicit empty params dict uses registration defaults.""" + # RSI has parameters={"period": 14} in registration, so empty params works features = [ - {"name": "sma", "params": {}}, + {"name": "rsi", "params": {}}, ] result = compute_features(sample_ohlcv_data, features) assert isinstance(result, pl.DataFrame) + + +def test_features_without_params_raises_clear_error(sample_ohlcv_data): + """Test that unregistered features raise a clear error.""" + with pytest.raises(ValueError, match="not found in registry"): + compute_features(sample_ohlcv_data, ["nonexistent_feature_xyz"]) diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..ecef2b8 --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,256 @@ +"""Tests for discovery/catalog.py - FeatureCatalog API.""" + +import pytest + +from ml4t.engineer.core.registry import FeatureMetadata, FeatureRegistry +from ml4t.engineer.discovery.catalog import FeatureCatalog + + +@pytest.fixture +def registry(): + """Create a test registry with known features.""" + reg = FeatureRegistry() + + reg.register( + FeatureMetadata( + name="rsi", + func=lambda close, period=14: None, + category="momentum", + description="Relative Strength Index", + formula="100 - 100 / (1 + RS)", + normalized=True, + ta_lib_compatible=True, + input_type="close", + output_type="indicator", + parameters={"period": 14}, + tags=["oscillator", "momentum"], + value_range=(0.0, 100.0), + lookback=lambda period=14, **_: period, + ) + ) + + reg.register( + FeatureMetadata( + name="sma", + func=lambda close, period=20: None, + category="trend", + description="Simple Moving Average", + formula="sum(close, period) / period", + normalized=False, + ta_lib_compatible=True, + input_type="close", + output_type="indicator", + parameters={"period": 20}, + tags=["trend", "average"], + lookback=lambda period=20, **_: period, + ) + ) + + reg.register( + FeatureMetadata( + name="atr", + func=lambda high, low, close, period=14: None, + category="volatility", + description="Average True Range", + formula="EMA(TR, period)", + normalized=False, + ta_lib_compatible=True, + input_type="OHLCV", + output_type="indicator", + parameters={"period": 14}, + tags=["volatility", "range"], + lookback=lambda period=14, **_: period, + ) + ) + + reg.register( + FeatureMetadata( + name="macd", + func=lambda close, fast=12, slow=26, signal=9: None, + category="momentum", + description="Moving Average Convergence Divergence", + formula="EMA(fast) - EMA(slow)", + normalized=False, + ta_lib_compatible=True, + input_type="close", + output_type="indicator", + parameters={"fast": 12, "slow": 26, "signal": 9}, + dependencies=["sma"], + tags=["momentum", "trend"], + lookback=lambda slow=26, signal=9, **_: slow + signal, + ) + ) + + return reg + + +@pytest.fixture +def catalog(registry): + """Create a FeatureCatalog wrapping test registry.""" + return FeatureCatalog(registry) + + +class TestFeatureCatalogList: + """Tests for FeatureCatalog.list().""" + + def test_list_all(self, catalog): + result = catalog.list() + assert len(result) == 4 + assert result == sorted(result), "Results should be alphabetically sorted" + + def test_list_by_category(self, catalog): + result = catalog.list(category="momentum") + assert set(result) == {"rsi", "macd"} + + def test_list_by_category_no_match(self, catalog): + result = catalog.list(category="nonexistent") + assert result == [] + + def test_list_normalized(self, catalog): + result = catalog.list(normalized=True) + assert result == ["rsi"] + + def test_list_not_normalized(self, catalog): + result = catalog.list(normalized=False) + assert set(result) == {"sma", "atr", "macd"} + + def test_list_ta_lib_compatible(self, catalog): + result = catalog.list(ta_lib_compatible=True) + assert len(result) == 4 + + def test_list_by_input_type(self, catalog): + result = catalog.list(input_type="close") + assert set(result) == {"rsi", "sma", "macd"} + + def test_list_by_input_type_ohlcv(self, catalog): + result = catalog.list(input_type="OHLCV") + assert result == ["atr"] + + def test_list_with_tags(self, catalog): + result = catalog.list(tags=["momentum"]) + assert set(result) == {"rsi", "macd"} + + def test_list_with_multiple_tags(self, catalog): + result = catalog.list(tags=["momentum", "trend"]) + assert result == ["macd"] + + def test_list_has_dependencies(self, catalog): + result = catalog.list(has_dependencies=True) + assert result == ["macd"] + + def test_list_no_dependencies(self, catalog): + result = catalog.list(has_dependencies=False) + assert set(result) == {"rsi", "sma", "atr"} + + def test_list_with_limit(self, catalog): + result = catalog.list(limit=2) + assert len(result) == 2 + + def test_list_combined_filters(self, catalog): + result = catalog.list(category="momentum", normalized=True) + assert result == ["rsi"] + + +class TestFeatureCatalogDescribe: + """Tests for FeatureCatalog.describe().""" + + def test_describe_existing(self, catalog): + info = catalog.describe("rsi") + assert info["name"] == "rsi" + assert info["category"] == "momentum" + assert info["normalized"] is True + assert info["ta_lib_compatible"] is True + assert info["formula"] == "100 - 100 / (1 + RS)" + assert info["parameters"] == {"period": 14} + assert info["value_range"] == (0.0, 100.0) + assert info["lookback_period"] == 14 + + def test_describe_with_dependencies(self, catalog): + info = catalog.describe("macd") + assert info["dependencies"] == ["sma"] + assert info["lookback_period"] == 35 # 26 + 9 + + def test_describe_not_found(self, catalog): + with pytest.raises(KeyError, match="not found"): + catalog.describe("nonexistent") + + +class TestFeatureCatalogSearch: + """Tests for FeatureCatalog.search().""" + + def test_search_by_name(self, catalog): + results = catalog.search("rsi") + assert len(results) > 0 + names = [name for name, _ in results] + assert "rsi" in names + + def test_search_exact_name_highest_score(self, catalog): + results = catalog.search("rsi") + assert results[0][0] == "rsi" + assert results[0][1] == 1.0 # Exact match + + def test_search_by_description(self, catalog): + results = catalog.search("Average True Range") + names = [name for name, _ in results] + assert "atr" in names + + def test_search_partial_match(self, catalog): + results = catalog.search("average") + names = [name for name, _ in results] + assert "sma" in names # "Simple Moving Average" + assert "atr" in names # "Average True Range" + + def test_search_empty_query(self, catalog): + results = catalog.search("") + assert results == [] + + def test_search_whitespace_query(self, catalog): + results = catalog.search(" ") + assert results == [] + + def test_search_max_results(self, catalog): + results = catalog.search("a", max_results=2) + assert len(results) <= 2 + + def test_search_specific_fields(self, catalog): + results = catalog.search("momentum", search_fields=["tags"]) + names = [name for name, _ in results] + assert "rsi" in names + assert "macd" in names + + +class TestFeatureCatalogConvenience: + """Tests for convenience methods.""" + + def test_by_input_type(self, catalog): + result = catalog.by_input_type("close") + assert set(result) == {"rsi", "sma", "macd"} + + def test_by_lookback(self, catalog): + result = catalog.by_lookback(14) + assert "rsi" in result + assert "atr" in result + assert "macd" not in result # lookback = 35 + + def test_categories(self, catalog): + result = catalog.categories() + assert result == ["momentum", "trend", "volatility"] + + def test_input_types(self, catalog): + result = catalog.input_types() + assert set(result) == {"close", "OHLCV"} + + def test_stats(self, catalog): + stats = catalog.stats() + assert stats["total"] == 4 + assert stats["by_category"]["momentum"] == 2 + assert stats["by_category"]["volatility"] == 1 + assert stats["normalized"] == 1 + assert stats["ta_lib_compatible"] == 4 + + def test_len(self, catalog): + assert len(catalog) == 4 + + def test_repr(self, catalog): + assert "FeatureCatalog" in repr(catalog) + assert "4" in repr(catalog) diff --git a/tests/test_experiment_config.py b/tests/test_experiment_config.py new file mode 100644 index 0000000..3f41392 --- /dev/null +++ b/tests/test_experiment_config.py @@ -0,0 +1,234 @@ +"""Tests for config/experiment.py - ExperimentConfig load/save/roundtrip.""" + +import pytest +import yaml + +from ml4t.engineer.config.experiment import ( + ExperimentConfig, + load_experiment_config, + save_experiment_config, +) +from ml4t.engineer.config.labeling import LabelingConfig +from ml4t.engineer.config.preprocessing_config import PreprocessingConfig + + +@pytest.fixture +def yaml_file(tmp_path): + """Create a YAML config file for testing.""" + config = { + "features": [ + {"name": "rsi", "params": {"period": 14}}, + {"name": "macd"}, + ], + "labeling": { + "method": "triple_barrier", + "upper_barrier": 0.02, + "lower_barrier": 0.01, + "max_holding_period": 20, + }, + "preprocessing": { + "scaler": "robust", + "quantile_range": [10.0, 90.0], + }, + } + path = tmp_path / "experiment.yaml" + with open(path, "w") as f: + yaml.dump(config, f) + return path + + +@pytest.fixture +def minimal_yaml(tmp_path): + """Create a minimal YAML with only features.""" + config = {"features": [{"name": "rsi"}]} + path = tmp_path / "minimal.yaml" + with open(path, "w") as f: + yaml.dump(config, f) + return path + + +class TestLoadExperimentConfig: + """Tests for load_experiment_config().""" + + def test_load_full_config(self, yaml_file): + config = load_experiment_config(yaml_file) + assert isinstance(config, ExperimentConfig) + assert len(config.features) == 2 + assert config.features[0]["name"] == "rsi" + assert config.features[0]["params"]["period"] == 14 + + def test_load_labeling_config(self, yaml_file): + config = load_experiment_config(yaml_file) + assert isinstance(config.labeling, LabelingConfig) + assert config.labeling.method == "triple_barrier" + assert config.labeling.upper_barrier == 0.02 + assert config.labeling.lower_barrier == 0.01 + assert config.labeling.max_holding_period == 20 + + def test_load_preprocessing_config(self, yaml_file): + config = load_experiment_config(yaml_file) + assert isinstance(config.preprocessing, PreprocessingConfig) + assert config.preprocessing.scaler == "robust" + assert config.preprocessing.quantile_range == (10.0, 90.0) + + def test_load_raw_preserved(self, yaml_file): + config = load_experiment_config(yaml_file) + assert "features" in config.raw + assert "labeling" in config.raw + assert "preprocessing" in config.raw + + def test_load_minimal(self, minimal_yaml): + config = load_experiment_config(minimal_yaml) + assert len(config.features) == 1 + assert config.labeling is None + assert config.preprocessing is None + + def test_load_file_not_found(self, tmp_path): + with pytest.raises(FileNotFoundError): + load_experiment_config(tmp_path / "nonexistent.yaml") + + def test_load_empty_yaml(self, tmp_path): + path = tmp_path / "empty.yaml" + path.write_text("") + config = load_experiment_config(path) + assert config.features == [] + assert config.labeling is None + assert config.preprocessing is None + + def test_load_no_validate(self, yaml_file): + config = load_experiment_config(yaml_file, validate=False) + assert config.labeling is not None + assert config.preprocessing is not None + + def test_load_string_path(self, yaml_file): + config = load_experiment_config(str(yaml_file)) + assert len(config.features) == 2 + + +class TestSaveExperimentConfig: + """Tests for save_experiment_config().""" + + def test_save_full_config(self, tmp_path): + config = ExperimentConfig( + features=[{"name": "rsi", "params": {"period": 14}}], + labeling=LabelingConfig.triple_barrier(upper_barrier=0.02, lower_barrier=0.01), + preprocessing=PreprocessingConfig.robust(), + ) + path = tmp_path / "output.yaml" + save_experiment_config(config, path) + assert path.exists() + + with open(path) as f: + raw = yaml.safe_load(f) + assert "features" in raw + assert "labeling" in raw + assert "preprocessing" in raw + + def test_save_minimal(self, tmp_path): + config = ExperimentConfig(features=[{"name": "sma"}]) + path = tmp_path / "minimal.yaml" + save_experiment_config(config, path) + + with open(path) as f: + raw = yaml.safe_load(f) + assert "features" in raw + assert "labeling" not in raw + assert "preprocessing" not in raw + + def test_save_empty(self, tmp_path): + config = ExperimentConfig() + path = tmp_path / "empty.yaml" + save_experiment_config(config, path) + assert path.exists() + + def test_save_include_defaults(self, tmp_path): + config = ExperimentConfig( + preprocessing=PreprocessingConfig.standard(), + ) + path = tmp_path / "with_defaults.yaml" + save_experiment_config(config, path, include_defaults=True) + + with open(path) as f: + raw = yaml.safe_load(f) + # With include_defaults=True, default fields should appear + assert "preprocessing" in raw + assert raw["preprocessing"]["scaler"] == "standard" + + +class TestRoundtrip: + """Tests for save → load roundtrip fidelity.""" + + def test_roundtrip_features(self, tmp_path): + original = ExperimentConfig( + features=[ + {"name": "rsi", "params": {"period": 14}}, + {"name": "macd", "params": {"fast": 12, "slow": 26}}, + ], + ) + path = tmp_path / "roundtrip.yaml" + save_experiment_config(original, path) + loaded = load_experiment_config(path) + assert loaded.features == original.features + + def test_roundtrip_labeling(self, tmp_path): + original = ExperimentConfig( + labeling=LabelingConfig.triple_barrier( + upper_barrier=0.03, + lower_barrier=0.015, + max_holding_period=30, + ), + ) + path = tmp_path / "roundtrip.yaml" + save_experiment_config(original, path, include_defaults=True) + loaded = load_experiment_config(path) + assert loaded.labeling is not None + assert loaded.labeling.upper_barrier == 0.03 + assert loaded.labeling.lower_barrier == 0.015 + assert loaded.labeling.max_holding_period == 30 + + def test_roundtrip_preprocessing(self, tmp_path): + original = ExperimentConfig( + preprocessing=PreprocessingConfig.robust(quantile_range=(5.0, 95.0)), + ) + path = tmp_path / "roundtrip.yaml" + save_experiment_config(original, path, include_defaults=True) + loaded = load_experiment_config(path) + assert loaded.preprocessing is not None + assert loaded.preprocessing.scaler == "robust" + assert loaded.preprocessing.quantile_range == (5.0, 95.0) + + def test_roundtrip_full(self, tmp_path): + original = ExperimentConfig( + features=[{"name": "rsi", "params": {"period": 20}}], + labeling=LabelingConfig.triple_barrier(upper_barrier=0.02), + preprocessing=PreprocessingConfig.standard(), + ) + path = tmp_path / "full.yaml" + save_experiment_config(original, path, include_defaults=True) + loaded = load_experiment_config(path) + + assert loaded.features == original.features + assert loaded.labeling is not None + assert loaded.labeling.upper_barrier == 0.02 + assert loaded.preprocessing is not None + assert loaded.preprocessing.scaler == "standard" + + +class TestExperimentConfigDataclass: + """Tests for ExperimentConfig dataclass.""" + + def test_defaults(self): + config = ExperimentConfig() + assert config.features == [] + assert config.labeling is None + assert config.preprocessing is None + assert config.raw == {} + + def test_with_values(self): + config = ExperimentConfig( + features=[{"name": "rsi"}], + labeling=LabelingConfig.triple_barrier(upper_barrier=0.02), + ) + assert len(config.features) == 1 + assert config.labeling is not None + assert config.preprocessing is None diff --git a/tests/test_integration_pipeline.py b/tests/test_integration_pipeline.py new file mode 100644 index 0000000..e0386c9 --- /dev/null +++ b/tests/test_integration_pipeline.py @@ -0,0 +1,430 @@ +"""Integration tests: OHLCV → features → labeling → preprocessing → MLDatasetBuilder. + +These tests verify the full pipeline a book reader would use, end-to-end. +No mocking — real computations with synthetic but realistic data. +""" + +from datetime import datetime, timedelta + +import numpy as np +import polars as pl +import pytest + +from ml4t.engineer.config import LabelingConfig, PreprocessingConfig +from ml4t.engineer.dataset import MLDatasetBuilder +from ml4t.engineer.labeling import ( + atr_triple_barrier_labels, + fixed_time_horizon_labels, + triple_barrier_labels, +) +from ml4t.engineer.preprocessing import RobustScaler, StandardScaler + + +@pytest.fixture +def ohlcv_data(): + """Realistic synthetic OHLCV data for a single asset (200 bars).""" + np.random.seed(42) + n = 200 + timestamps = [datetime(2024, 1, 1) + timedelta(hours=i) for i in range(n)] + + # Generate correlated OHLCV with realistic structure + close = 100.0 + np.cumsum(np.random.randn(n) * 0.5) + high = close + np.abs(np.random.randn(n) * 0.3) + low = close - np.abs(np.random.randn(n) * 0.3) + open_ = close + np.random.randn(n) * 0.1 + volume = np.abs(np.random.randn(n) * 1000 + 5000) + + return pl.DataFrame( + { + "timestamp": timestamps, + "open": open_.tolist(), + "high": high.tolist(), + "low": low.tolist(), + "close": close.tolist(), + "volume": volume.tolist(), + } + ) + + +@pytest.fixture +def panel_data(): + """Multi-asset OHLCV data for panel testing (2 assets, 100 bars each).""" + np.random.seed(123) + n = 100 + + frames = [] + for symbol in ["AAPL", "MSFT"]: + timestamps = [datetime(2024, 1, 1) + timedelta(hours=i) for i in range(n)] + close = 100.0 + np.cumsum(np.random.randn(n) * 0.3) + high = close + np.abs(np.random.randn(n) * 0.2) + low = close - np.abs(np.random.randn(n) * 0.2) + open_ = close + np.random.randn(n) * 0.05 + volume = np.abs(np.random.randn(n) * 500 + 3000) + + frames.append( + pl.DataFrame( + { + "timestamp": timestamps, + "symbol": [symbol] * n, + "open": open_.tolist(), + "high": high.tolist(), + "low": low.tolist(), + "close": close.tolist(), + "volume": volume.tolist(), + } + ) + ) + return pl.concat(frames) + + +class TestFixedHorizonPipeline: + """Test: OHLCV → fixed_time_horizon_labels → scaler → numpy.""" + + def test_bar_based_labels_to_numpy(self, ohlcv_data): + # Label + labeled = fixed_time_horizon_labels( + ohlcv_data, + horizon=5, + method="returns", + price_col="close", + group_col=[], + ) + assert "label_return_5p" in labeled.columns + assert labeled.shape[0] == ohlcv_data.shape[0] + + # The last 5 rows should be null (no future data) + label_col = labeled["label_return_5p"] + assert label_col[-1] is None + + def test_time_based_labels(self, ohlcv_data): + labeled = fixed_time_horizon_labels( + ohlcv_data, + horizon="2h", + method="returns", + price_col="close", + timestamp_col="timestamp", + group_col=[], + ) + assert any("label_return" in c for c in labeled.columns) + + def test_binary_labels(self, ohlcv_data): + labeled = fixed_time_horizon_labels( + ohlcv_data, + horizon=1, + method="binary", + price_col="close", + group_col=[], + ) + label_col = "label_direction_1p" + assert label_col in labeled.columns + # Non-null values should be in {-1, 0, 1} + non_null = labeled.filter(pl.col(label_col).is_not_null())[label_col] + assert set(non_null.to_list()).issubset({-1, 0, 1}) + + def test_panel_data_labels(self, panel_data): + labeled = fixed_time_horizon_labels( + panel_data, + horizon=5, + method="returns", + price_col="close", + group_col="symbol", + timestamp_col="timestamp", + ) + assert labeled.shape[0] == panel_data.shape[0] + # Both assets should have labels + per_asset = labeled.group_by("symbol").agg( + pl.col("label_return_5p").is_not_null().sum().alias("non_null_count") + ) + for row in per_asset.iter_rows(named=True): + assert row["non_null_count"] > 0 + + +class TestTripleBarrierPipeline: + """Test: OHLCV → triple_barrier_labels → preprocessing → numpy.""" + + def test_basic_labeling(self, ohlcv_data): + config = LabelingConfig.triple_barrier( + upper_barrier=0.02, + lower_barrier=0.01, + max_holding_period=20, + ) + labeled = triple_barrier_labels( + ohlcv_data, + config=config, + price_col="close", + timestamp_col="timestamp", + group_col=[], + ) + assert "label" in labeled.columns + assert "label_return" in labeled.columns + assert "barrier_hit" in labeled.columns + + # Labels should be in {-1, 0, 1} + non_null = labeled.filter(pl.col("label").is_not_null())["label"] + assert set(non_null.to_list()).issubset({-1, 0, 1}) + + def test_atr_labeling(self, ohlcv_data): + labeled = atr_triple_barrier_labels( + ohlcv_data, + atr_tp_multiple=2.0, + atr_sl_multiple=1.0, + atr_period=14, + max_holding_bars=30, + price_col="close", + timestamp_col="timestamp", + group_col=[], + ) + assert "label" in labeled.columns + assert "atr" in labeled.columns + assert "upper_barrier_distance" in labeled.columns + assert "lower_barrier_distance" in labeled.columns + + def test_panel_triple_barrier(self, panel_data): + config = LabelingConfig.triple_barrier( + upper_barrier=0.02, + lower_barrier=0.01, + max_holding_period=15, + ) + labeled = triple_barrier_labels( + panel_data, + config=config, + price_col="close", + timestamp_col="timestamp", + group_col="symbol", + ) + assert labeled.shape[0] == panel_data.shape[0] + # Both assets should have labels + per_asset = labeled.group_by("symbol").agg( + pl.col("label").is_not_null().sum().alias("label_count") + ) + for row in per_asset.iter_rows(named=True): + assert row["label_count"] > 0 + + +class TestPreprocessingPipeline: + """Test: labeled data → scaler → numpy arrays.""" + + def test_standard_scaler_fit_transform(self, ohlcv_data): + scaler = StandardScaler(columns=["close", "volume"]) + scaled = scaler.fit_transform(ohlcv_data) + + # Scaled columns should have ~0 mean and ~1 std + close_mean = scaled["close"].mean() + close_std = scaled["close"].std() + assert abs(close_mean) < 0.01 + assert abs(close_std - 1.0) < 0.01 + + # Non-scaled columns should be unchanged + assert scaled["timestamp"].to_list() == ohlcv_data["timestamp"].to_list() + assert scaled["open"].to_list() == ohlcv_data["open"].to_list() + + def test_robust_scaler(self, ohlcv_data): + scaler = RobustScaler(columns=["close", "volume"]) + scaled = scaler.fit_transform(ohlcv_data) + assert scaled.shape == ohlcv_data.shape + + def test_scaler_from_config(self, ohlcv_data): + config = PreprocessingConfig.robust(quantile_range=(10.0, 90.0)) + scaler = config.create_scaler() + assert scaler is not None + scaled = scaler.fit_transform(ohlcv_data) + assert scaled.shape == ohlcv_data.shape + + def test_scaler_preserves_column_order(self, ohlcv_data): + scaler = StandardScaler(columns=["volume", "close"]) + scaled = scaler.fit_transform(ohlcv_data) + assert scaled.columns == ohlcv_data.columns + + +class TestMLDatasetBuilder: + """Test: full pipeline → MLDatasetBuilder → numpy arrays.""" + + def test_basic_dataset_build(self, ohlcv_data): + # Add a simple feature and label + data = ohlcv_data.with_columns( + (pl.col("close").pct_change()).alias("returns"), + (pl.col("close").shift(-1) > pl.col("close")).cast(pl.Int8).alias("label"), + ) + clean = data.drop_nulls(subset=["returns", "label"]) + + features = clean.select(["close", "volume", "returns"]) + labels = clean["label"] + + builder = MLDatasetBuilder(features=features, labels=labels) + + # train_test_split returns Polars DataFrames/Series + X_train, X_test, y_train, y_test = builder.train_test_split(train_size=0.75) + assert isinstance(X_train, pl.DataFrame) + assert isinstance(y_train, pl.Series) + assert X_train.shape[1] == 3 # 3 features + assert len(X_train) + len(X_test) == len(clean) + + def test_to_numpy(self, ohlcv_data): + """Test conversion to numpy arrays.""" + data = ohlcv_data.with_columns( + (pl.col("close").pct_change()).alias("returns"), + (pl.col("close").shift(-1) > pl.col("close")).cast(pl.Int8).alias("label"), + ) + clean = data.drop_nulls(subset=["returns", "label"]) + + features = clean.select(["close", "volume", "returns"]) + labels = clean["label"] + + builder = MLDatasetBuilder(features=features, labels=labels) + X, y = builder.to_numpy() + assert isinstance(X, np.ndarray) + assert isinstance(y, np.ndarray) + assert X.shape == (len(clean), 3) + + def test_dataset_with_scaler(self, ohlcv_data): + data = ohlcv_data.with_columns( + (pl.col("close").pct_change()).alias("returns"), + (pl.col("close").shift(-5) > pl.col("close")).cast(pl.Int8).alias("label"), + ) + clean = data.drop_nulls(subset=["returns", "label"]) + + features = clean.select(["close", "volume", "returns"]) + labels = clean["label"] + + builder = MLDatasetBuilder(features=features, labels=labels) + builder.set_scaler(StandardScaler(columns=["close", "volume"])) + + X_train, X_test, y_train, y_test = builder.train_test_split(train_size=0.75) + assert X_train.shape[1] == 3 + + def test_dataset_dates(self, ohlcv_data): + """Test that dates are preserved through the builder.""" + data = ohlcv_data.with_columns( + (pl.col("close").pct_change()).alias("returns"), + (pl.col("close").shift(-1) > pl.col("close")).cast(pl.Int8).alias("label"), + ) + clean = data.drop_nulls(subset=["returns", "label"]) + + features = clean.select(["close", "volume", "returns"]) + labels = clean["label"] + dates = clean["timestamp"] + + builder = MLDatasetBuilder(features=features, labels=labels, dates=dates) + assert builder.dates is not None + assert len(builder.dates) == len(features) + + +class TestEndToEndPipeline: + """Full end-to-end: OHLCV → label → preprocess → dataset → numpy.""" + + def test_full_pipeline_fixed_horizon(self, ohlcv_data): + """Complete pipeline a book reader would follow.""" + # Step 1: Label with fixed horizon + labeled = fixed_time_horizon_labels( + ohlcv_data, + horizon=5, + method="returns", + price_col="close", + group_col=[], + ) + assert "label_return_5p" in labeled.columns + + # Step 2: Add features + enriched = labeled.with_columns( + (pl.col("close").pct_change()).alias("returns_1"), + (pl.col("close").pct_change(5)).alias("returns_5"), + ((pl.col("high") - pl.col("low")) / pl.col("close")).alias("hl_range"), + ) + + # Step 3: Clean nulls and build dataset + feature_cols = ["returns_1", "returns_5", "hl_range"] + label_col = "label_return_5p" + clean = enriched.drop_nulls(subset=feature_cols + [label_col]) + + features = clean.select(feature_cols) + labels = clean[label_col] + + builder = MLDatasetBuilder(features=features, labels=labels) + X_train, X_test, y_train, y_test = builder.train_test_split(train_size=0.75) + + assert isinstance(X_train, pl.DataFrame) + assert X_train.shape[1] == 3 + assert X_train.shape[0] > 0 + + # Verify numpy conversion works + X_np, y_np = builder.to_numpy() + assert isinstance(X_np, np.ndarray) + assert X_np.shape[1] == 3 + + def test_full_pipeline_triple_barrier(self, ohlcv_data): + """Triple barrier → features → scaler → dataset.""" + # Step 1: Triple barrier labeling + config = LabelingConfig.triple_barrier( + upper_barrier=0.02, + lower_barrier=0.01, + max_holding_period=20, + ) + labeled = triple_barrier_labels( + ohlcv_data, + config=config, + price_col="close", + timestamp_col="timestamp", + group_col=[], + ) + + # Step 2: Add features + enriched = labeled.with_columns( + (pl.col("close").pct_change()).alias("returns"), + ((pl.col("high") - pl.col("low")) / pl.col("close")).alias("range_pct"), + (pl.col("volume") / pl.col("volume").rolling_mean(20)).alias("volume_ratio"), + ) + + # Step 3: Clean and build dataset + feature_cols = ["returns", "range_pct", "volume_ratio"] + clean = enriched.drop_nulls(subset=feature_cols + ["label"]) + + features = clean.select(feature_cols) + labels = clean["label"] + + builder = MLDatasetBuilder(features=features, labels=labels) + builder.set_scaler(StandardScaler(columns=feature_cols)) + + X_train, X_test, y_train, y_test = builder.train_test_split(train_size=0.75) + assert X_train.shape[1] == 3 + assert X_train.shape[0] > 0 + # Labels should be in {-1, 0, 1} + assert set(y_train.to_numpy().astype(int)).issubset({-1, 0, 1}) + + +class TestNaNValidation: + """Test that NaN validation catches bad data early.""" + + def test_triple_barrier_rejects_nan_prices(self, ohlcv_data): + from ml4t.engineer.core.exceptions import DataValidationError + + bad_data = ohlcv_data.with_columns( + pl.when(pl.col("close").is_first_distinct()) + .then(float("nan")) + .otherwise(pl.col("close")) + .alias("close") + ) + config = LabelingConfig.triple_barrier( + upper_barrier=0.02, + lower_barrier=0.01, + max_holding_period=20, + ) + with pytest.raises(DataValidationError, match="null/NaN"): + triple_barrier_labels( + bad_data, + config=config, + price_col="close", + timestamp_col="timestamp", + group_col=[], + ) + + def test_fixed_horizon_rejects_nan_prices(self, ohlcv_data): + from ml4t.engineer.core.exceptions import DataValidationError + + bad_data = ohlcv_data.with_columns(pl.lit(None, dtype=pl.Float64).alias("close")) + with pytest.raises(DataValidationError, match="null/NaN"): + fixed_time_horizon_labels( + bad_data, + horizon=5, + price_col="close", + group_col=[], + ) diff --git a/tests/test_labeling.py b/tests/test_labeling.py index 6337ff6..318a753 100644 --- a/tests/test_labeling.py +++ b/tests/test_labeling.py @@ -300,7 +300,9 @@ def test_single_row(self): assert result["label"][0] == 0 def test_all_nan_prices(self): - """Test with all NaN prices.""" + """Test with all NaN prices - validation catches at entry.""" + from ml4t.engineer.core.exceptions import DataValidationError + df = pl.DataFrame( { "timestamp": [datetime(2024, 1, 1) + timedelta(minutes=i) for i in range(10)], @@ -309,10 +311,8 @@ def test_all_nan_prices(self): ) config = LabelingConfig.triple_barrier(upper_barrier=0.02, lower_barrier=-0.01) - result = triple_barrier_labels(df, config, price_col="price") - # With NaN prices, all events timeout (label=0) and returns are NaN - assert (result["label"] == 0).all() - assert result["label_return"].is_nan().all() + with pytest.raises(DataValidationError, match="null/NaN"): + triple_barrier_labels(df, config, price_col="price") def test_invalid_barriers(self): """Test with invalid barrier configuration.""" diff --git a/tests/test_labeling_calendar.py b/tests/test_labeling_calendar.py index 11a63e2..6ff343c 100644 --- a/tests/test_labeling_calendar.py +++ b/tests/test_labeling_calendar.py @@ -486,7 +486,9 @@ def test_multiple_calendar_types(self, sample_daily_data): assert "label" in result_simple.columns def test_calendar_with_nan_prices(self, sample_intraday_data): - """Test calendar-aware labels with NaN prices.""" + """Test calendar-aware labels rejects NaN prices at entry.""" + from ml4t.engineer.core.exceptions import DataValidationError + # Add some NaN values data_with_nan = sample_intraday_data.with_columns( pl.when(pl.col("close") > 100.5).then(None).otherwise(pl.col("close")).alias("close") @@ -501,13 +503,12 @@ def test_calendar_with_nan_prices(self, sample_intraday_data): max_holding_period=5, ) - # Should not crash - result = calendar_aware_labels( - data_with_nan, - config, - calendar=cal, - price_col="close", - timestamp_col="timestamp", - ) - - assert "label" in result.columns + # NaN validation catches bad prices at entry + with pytest.raises(DataValidationError, match="null/NaN"): + calendar_aware_labels( + data_with_nan, + config, + calendar=cal, + price_col="close", + timestamp_col="timestamp", + ) diff --git a/tests/visualization/test_summary.py b/tests/visualization/test_summary.py index 63b5e0e..0803129 100644 --- a/tests/visualization/test_summary.py +++ b/tests/visualization/test_summary.py @@ -1,39 +1,18 @@ -"""Tests for unified visualization module. - -.. deprecated:: 0.1.0a8 - Feature outcome visualization has moved to ml4t-diagnostic. - Use ml4t.diagnostic.visualization instead. - -These tests require ml4t-diagnostic to be installed for the mock fixtures. -""" +"""Tests for plot export utility.""" from __future__ import annotations import pytest -# These tests require ml4t-diagnostic for the data classes -try: - from ml4t.diagnostic.evaluation import FeatureOutcome # noqa: F401 - - HAS_DIAGNOSTIC = True -except ImportError: - HAS_DIAGNOSTIC = False - from ml4t.engineer.visualization import export_plot -pytestmark = pytest.mark.skipif( - not HAS_DIAGNOSTIC, - reason="Visualization tests require ml4t-diagnostic. Install with: pip install ml4t-diagnostic", -) - class TestExportPlot: - """Test export_plot utility function (always available).""" + """Test export_plot utility function.""" def test_export_requires_figure(self, tmp_path): """Test that export_plot validates figure argument.""" output_path = tmp_path / "test.png" - # Should raise error with invalid figure with pytest.raises((AttributeError, TypeError)): export_plot(None, output_path) @@ -70,8 +49,3 @@ def test_export_creates_directory(self, tmp_path): assert output_path.stat().st_size > 0 plt.close(fig) - - -# Note: Feature analysis summary visualization tests have moved to ml4t-diagnostic. -# The plot_feature_analysis_summary function is deprecated and will raise -# NotImplementedError directing users to ml4t.diagnostic.visualization. From d23e3d846f185e9ea12b3911c7657ace334b76fc Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Tue, 3 Mar 2026 09:04:41 -0500 Subject: [PATCH 2/3] fix: prefix unused lambda args in test_catalog.py to satisfy ruff ARG005 --- tests/test_catalog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_catalog.py b/tests/test_catalog.py index ecef2b8..8ebaed6 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -14,7 +14,7 @@ def registry(): reg.register( FeatureMetadata( name="rsi", - func=lambda close, period=14: None, + func=lambda _close, _period=14: None, category="momentum", description="Relative Strength Index", formula="100 - 100 / (1 + RS)", @@ -32,7 +32,7 @@ def registry(): reg.register( FeatureMetadata( name="sma", - func=lambda close, period=20: None, + func=lambda _close, _period=20: None, category="trend", description="Simple Moving Average", formula="sum(close, period) / period", @@ -49,7 +49,7 @@ def registry(): reg.register( FeatureMetadata( name="atr", - func=lambda high, low, close, period=14: None, + func=lambda _high, _low, _close, _period=14: None, category="volatility", description="Average True Range", formula="EMA(TR, period)", @@ -66,7 +66,7 @@ def registry(): reg.register( FeatureMetadata( name="macd", - func=lambda close, fast=12, slow=26, signal=9: None, + func=lambda _close, _fast=12, _slow=26, _signal=9: None, category="momentum", description="Moving Average Convergence Divergence", formula="EMA(fast) - EMA(slow)", From 62557d27f9c5591a66adbd3767b0ec35e5a60444 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Tue, 3 Mar 2026 09:16:00 -0500 Subject: [PATCH 3/3] fix: ruff format and relax flaky hypothesis deadline --- src/ml4t/engineer/api.py | 1 + src/ml4t/engineer/labeling/utils.py | 1 + tests/test_property_based.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ml4t/engineer/api.py b/src/ml4t/engineer/api.py index 184a22b..284d553 100644 --- a/src/ml4t/engineer/api.py +++ b/src/ml4t/engineer/api.py @@ -78,6 +78,7 @@ } ) + def compute_features( data: pl.DataFrame | pl.LazyFrame, features: list[str] | list[dict[str, Any]] | Path | str, diff --git a/src/ml4t/engineer/labeling/utils.py b/src/ml4t/engineer/labeling/utils.py index 5bb0824..d80679a 100644 --- a/src/ml4t/engineer/labeling/utils.py +++ b/src/ml4t/engineer/labeling/utils.py @@ -40,6 +40,7 @@ def validate_price_no_nans(data: pl.DataFrame, price_col: str) -> None: f"(out of {len(data)} rows). Clean data before labeling." ) + # Duration string regex pattern (e.g., "1h", "30m", "1d2h30m") _DURATION_PATTERN = re.compile( r"^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$", diff --git a/tests/test_property_based.py b/tests/test_property_based.py index 5453ee7..8d810c4 100644 --- a/tests/test_property_based.py +++ b/tests/test_property_based.py @@ -325,7 +325,7 @@ def test_cross_asset_correlation_bounds(self, asset1, asset2): assert (corr_values <= 1.0).all() @given(returns_series(min_size=60)) - @settings(max_examples=20, deadline=3000) + @settings(max_examples=20, deadline=5000) def test_regime_indicators_bounds(self, returns): """Test that regime indicators produce reasonable bounds.""" # Convert returns to cumulative prices (hurst_exponent needs prices not returns)