From 4e47670b3c7ae2c913f737289ae546d7ac0c4371 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:23:17 -0400 Subject: [PATCH 01/28] fix: preserve shared calendar intent in session-aware labeling (#94) --- docs/user-guide/labeling.md | 54 ++++++ src/ml4t/engineer/config/labeling.py | 41 ++++- src/ml4t/engineer/core/bar_grid.py | 21 ++- src/ml4t/engineer/labeling/AGENTS.md | 17 ++ src/ml4t/engineer/labeling/calendar.py | 192 ++++++++++++++++++--- src/ml4t/engineer/labeling/utils.py | 14 ++ tests/test_labeling_time_contract.py | 228 +++++++++++++++++++++++++ 7 files changed, 540 insertions(+), 27 deletions(-) create mode 100644 tests/test_labeling_time_contract.py diff --git a/docs/user-guide/labeling.md b/docs/user-guide/labeling.md index da9d314..bdfeb46 100644 --- a/docs/user-guide/labeling.md +++ b/docs/user-guide/labeling.md @@ -414,6 +414,12 @@ result = apply_meta_model( For futures and other instruments with defined trading sessions, calendar-aware labeling respects session boundaries. A label that would span an overnight gap is handled correctly. +In the next minor version, `calendar_aware_labels` uses the shared temporal contract. +An explicit `calendar` argument wins over a declared `LabelingConfig.calendar`, then +`FeedSpec` metadata, then NYSE. Omitting a selector differs from explicit `None`. +Config JSON/YAML output preserves that distinction and emits only explicitly declared +column overrides, so a round trip does not replace feed column mappings with defaults. + ```python from ml4t.engineer.labeling import calendar_aware_labels @@ -433,6 +439,54 @@ former pause because trading is continuous until 4:00 PM. See the [CME exchange notice](https://www.cmegroup.com/notices/electronic-trading/2021/06/20210621.html) for the affected equity-index contracts. +### Observation rows, expected bars and elapsed durations + +Use `calendar=None` for observation-row labeling, including timestamp-indexed data: + +```python +row_config = LabelingConfig.triple_barrier( + upper_barrier=0.02, + lower_barrier=0.02, + max_holding_period=10, + calendar=None, + window_unit="observation", +) +labels = calendar_aware_labels(df, row_config) +``` + +Row mode retains input order, processes each asset independently, does not require +timestamps and never loads a schedule provider. Its `label_bars` and `label_time` are +observation counts and per-asset row offsets; `label_duration` is unset. Timestamp +spacing and weekend placement do not alter its numerical labels. Duration holding +periods are incompatible with this mode. + +Named calendars segment authoritative sessions and breaks. New shared-feed calls and +omitted selectors default count windows to `"expected_bar"`; an undeclared or incomplete +grid fails before labeling. Supply the complete shared grid +declarations described in the [feature guide](features.md#calendar-and-bar-grid) to reject +missing scheduled bars before labeling. Named `"24/7"` still has daily schedule boundaries +and is not equivalent to row mode. `"session"` count windows are not implemented and fail +explicitly. Duration holding periods select `"elapsed"` and remain truncated at session +boundaries, rather than counting sessions or observed dates. + +Pass `feed_spec=FeedSpec(...)` directly or through `LabelingConfig.feed_spec`. Shared +`timestamp_semantics` must declare an intraday instant; `session_label` is rejected. +Naive feed timestamps require an explicit timezone, and ambiguous/nonexistent local +times fail. Right-labeled/bar-close observations at a break boundary end the preceding +tradable interval. Input timestamp representations are retained in the output. + +Legacy calls without FeedSpec keep naive-UTC handling. Custom protocol objects and +explicit `calendar="auto"` remain compatibility modes without shared feed metadata. +An explicit legacy calendar selector without FeedSpec retains observation-count horizons. +New calls can also select `window_unit="observation"` explicitly to count received rows +within each authoritative segment, without claiming scheduled-bar completeness. +Auto-gap segmentation does not establish market holidays, session completeness or a +bar grid. Standalone labeling kernels retain their documented row-count or elapsed-time +definitions and chronological sorting; they do not acquire calendar interpretation from +timestamps. Use this wrapper when selecting the shared calendar/row contract. +Standalone kernels reject configs declaring calendar/feed/window semantics instead +of silently ignoring those declarations. + ## Sample Weighting AFML Chapter 4 addresses the problem of overlapping labels creating correlated samples. The library provides the full toolkit: diff --git a/src/ml4t/engineer/config/labeling.py b/src/ml4t/engineer/config/labeling.py index b67941b..fce13f2 100644 --- a/src/ml4t/engineer/config/labeling.py +++ b/src/ml4t/engineer/config/labeling.py @@ -28,7 +28,15 @@ from datetime import timedelta from typing import Any, Literal -from pydantic import AliasChoices, Field, field_serializer, field_validator +from ml4t.specs import FeedSpec, WindowUnit +from pydantic import ( + AliasChoices, + Field, + SerializerFunctionWrapHandler, + field_serializer, + field_validator, + model_serializer, +) from ml4t.engineer.config.base import ( BaseConfig, @@ -145,6 +153,37 @@ class LabelingConfig(BaseConfig): validation_alias=AliasChoices("data_contract", "contract"), description="Optional shared dataframe column contract for cross-library workflows", ) + calendar: str | None = Field( + None, + description="Declared calendar selector; omitted inherits feed metadata, explicit null disables", + ) + feed_spec: dict[str, Any] | None = Field( + None, description="Shared FeedSpec declarations for calendar-aware labeling" + ) + window_unit: WindowUnit | None = Field( + None, + description="Observation, expected_bar, session or elapsed; consumer validates support", + ) + + @field_validator("calendar", mode="before") + @classmethod + def validate_calendar(cls, value: Any) -> str | None: + return FeedSpec(time_contract_version=2).resolve_calendar(calendar=value) + + @field_validator("feed_spec", mode="before") + @classmethod + def validate_feed_spec(cls, value: Any) -> dict[str, Any] | None: + return None if value is None else FeedSpec.from_any(value).to_dict() + + @model_serializer(mode="wrap") + def serialize_declarations(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + payload = handler(self) + for name in ("calendar", "price_col", "timestamp_col", "group_col", "data_contract"): + if name not in self.model_fields_set: + payload.pop(name, None) + if "calendar" in self.model_fields_set and self.calendar is None: + payload["calendar"] = None + return payload # Triple barrier parameters upper_barrier: float | str | None = Field( diff --git a/src/ml4t/engineer/core/bar_grid.py b/src/ml4t/engineer/core/bar_grid.py index 5155e65..dc8e331 100644 --- a/src/ml4t/engineer/core/bar_grid.py +++ b/src/ml4t/engineer/core/bar_grid.py @@ -13,7 +13,7 @@ from ml4t.engineer.labeling.calendar import PandasMarketCalendar -def validate_feature_grid( +def validate_declared_grid( data: pl.DataFrame | pl.LazyFrame, *, feed_spec: FeedSpec, @@ -78,6 +78,25 @@ def validate_feature_grid( label_side=cast(Literal["left", "right"], feed_spec.bar_label), include_partial=feed_spec.partial_bar_policy == "include", ) + + +def validate_feature_grid( + data: pl.DataFrame | pl.LazyFrame, + *, + feed_spec: FeedSpec, + calendar: str, + timestamp_col: str | None, + group_cols: list[str], +) -> None: + """Validate the declared grid and supplied returns against adjacent prices.""" + validate_declared_grid( + data, + feed_spec=feed_spec, + calendar=calendar, + timestamp_col=timestamp_col, + group_cols=group_cols, + ) + schema = data.collect_schema() if isinstance(data, pl.LazyFrame) else data.schema if "returns" in schema: ordered = data.sort([*group_cols, timestamp_col]) missing_price = ~pl.col("close").cast(pl.Float64).is_finite().fill_null(False) diff --git a/src/ml4t/engineer/labeling/AGENTS.md b/src/ml4t/engineer/labeling/AGENTS.md index 057e8b5..b37031b 100644 --- a/src/ml4t/engineer/labeling/AGENTS.md +++ b/src/ml4t/engineer/labeling/AGENTS.md @@ -69,3 +69,20 @@ Uniqueness score per label. Legacy module notes: - `core.py`, `barriers.py`, and `barrier_utils.py` are removed compatibility paths that raise import-time migration errors. + +## Temporal contract boundary + +`calendar_aware_labels` consumes shared Specs declarations. Use canonical `calendar`: +explicit argument, declared config selector, feed metadata, then NYSE. Explicit None is +observation-row mode without timestamp sorting or provider loading. Never turn that +opt-out into a continuous schedule. Config serialization must preserve declaration presence. + +Named schedules segment actual sessions/breaks. Observation counts are not expected-bar +counts; expected-bar mode validates the declared grid before labeling. Session-count +windows are unsupported, and elapsed durations remain distinct from count horizons. +Right-labeled bars can end at a break start; event timestamps at that instant are off-session. + +Standalone numerical labeling functions retain their legacy chronological row/duration +definitions. Do not infer calendar adoption from a timestamp parameter or change numerical +definitions without explicit migration evidence. Legacy auto-gap mode is not an authoritative +calendar or completeness check. Keep this boundary explicit in task guidance and user docs. diff --git a/src/ml4t/engineer/labeling/calendar.py b/src/ml4t/engineer/labeling/calendar.py index 9f4f7c6..14db9f2 100644 --- a/src/ml4t/engineer/labeling/calendar.py +++ b/src/ml4t/engineer/labeling/calendar.py @@ -8,15 +8,21 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import UTC, date, datetime, timedelta from inspect import signature from typing import TYPE_CHECKING, Any, Protocol import polars as pl +from ml4t.specs import UNSET_CALENDAR, CalendarUnset, FeedSpec, TimestampSemantics, WindowUnit from ml4t.engineer.core.exceptions import DataValidationError -from ml4t.engineer.labeling.triple_barrier import triple_barrier_labels -from ml4t.engineer.labeling.utils import resolve_labeling_columns +from ml4t.engineer.labeling.triple_barrier import _triple_barrier_labels_single_group +from ml4t.engineer.labeling.utils import ( + is_duration_string, + resolve_labeling_columns, + validate_price_no_nans, +) if TYPE_CHECKING: # pragma: no cover - imports used only by static analysis from ml4t.engineer.config import DataContractConfig, LabelingConfig @@ -219,13 +225,20 @@ def _contains_timestamp( """Check timestamp membership with explicit close-boundary semantics.""" return start <= timestamp <= end if end_inclusive else start <= timestamp < end - def _trading_boundary(self, timestamp: datetime) -> tuple[bool, datetime | None]: + def _trading_boundary( + self, timestamp: datetime, *, bar_close: bool = False + ) -> tuple[bool, datetime | None]: """Return whether a timestamp is trading and its next session boundary.""" normalized = self._normalize_timestamp(timestamp) for start, end, boundary, end_inclusive in self._trading_intervals_around( normalized.date() ): - if self._contains_timestamp(normalized, start, end, end_inclusive): + contains = ( + start < normalized <= end + if bar_close + else self._contains_timestamp(normalized, start, end, end_inclusive) + ) + if contains: return True, boundary return False, None @@ -269,26 +282,39 @@ def _calendar_session_ids( calendar: TradingCalendar, timestamp_col: str, group_cols: list[str], + timezone: str | None = None, + bar_close: bool = False, ) -> tuple[pl.DataFrame, list[int]]: """Sort rows and derive sessions from an explicit calendar.""" _validate_explicit_calendar(calendar) sort_cols = [*group_cols, timestamp_col] sorted_data = data.sort(sort_cols) selected_cols = [*group_cols, timestamp_col] + projection = sorted_data.select(selected_cols) + dtype = projection.schema[timestamp_col] + if isinstance(dtype, pl.Datetime): + if projection.select((pl.col(timestamp_col).dt.nanosecond() % 1000 != 0).any()).item(): + raise ValueError("calendar timestamp has finer than supported microsecond precision") + if dtype.time_zone is None and timezone is not None: + projection = projection.with_columns( + pl.col(timestamp_col) + .dt.replace_time_zone(timezone, ambiguous="raise", non_existent="raise") + .dt.convert_time_zone("UTC") + ) session_ids: list[int] = [] previous_group: tuple[Any, ...] | None = None previous_break: datetime | None = None session_id = -1 - for row in sorted_data.select(selected_cols).iter_rows(named=False): + for row in projection.iter_rows(named=False): group = tuple(row[: len(group_cols)]) timestamp = row[-1] if not isinstance(timestamp, datetime): raise TypeError(f"timestamp column {timestamp_col!r} must contain datetime values") if isinstance(calendar, PandasMarketCalendar): - is_trading, session_break = calendar._trading_boundary(timestamp) + is_trading, session_break = calendar._trading_boundary(timestamp, bar_close=bar_close) else: is_trading = calendar.is_trading_time(timestamp) session_break = calendar.next_session_break(timestamp) if is_trading else None @@ -326,14 +352,38 @@ def _calendar_session_ids( return sorted_data, session_ids +def _segment_labels( + data: pl.DataFrame, + config: LabelingConfig, + price_col: str, + group_cols: list[str], + timestamp_col: str | None = None, +) -> pl.DataFrame: + validate_price_no_nans(data, price_col) + row_col = _temporary_column_name(data.columns, "__ml4t_observation_row") + indexed = data.with_row_index(row_col) + frames = indexed.partition_by(group_cols, maintain_order=True) if group_cols else [indexed] + frames = frames or [indexed] + results = [ + _triple_barrier_labels_single_group( + frame, config, price_col, None, None, None, timestamp_col, False, "returns_uniqueness" + ) + for frame in frames + ] + return pl.concat(results, how="vertical").sort(row_col).drop(row_col) + + def calendar_aware_labels( data: pl.DataFrame, config: LabelingConfig, - calendar: str | TradingCalendar, + calendar: str | TradingCalendar | None | CalendarUnset = UNSET_CALENDAR, price_col: str | None = None, timestamp_col: str | None = None, group_col: str | list[str] | None = None, contract: DataContractConfig | None = None, + *, + feed_spec: FeedSpec | Mapping[str, Any] | None = None, + window_unit: WindowUnit | str | None = None, ) -> pl.DataFrame: """Apply triple-barrier labeling with session awareness. @@ -346,11 +396,10 @@ def calendar_aware_labels( Input data with OHLCV and timestamp config : LabelingConfig Barrier configuration - calendar : str or TradingCalendar - Either: - - Calendar name string (uses pandas_market_calendars) - - TradingCalendar protocol implementation - - "auto" to detect gaps automatically + calendar : str, TradingCalendar or None, optional + Explicit selector overrides config, then FeedSpec metadata, then NYSE. + None selects observation-row labeling without a schedule. Protocol objects + and explicit legacy "auto" gap mode remain available without FeedSpec. price_col : str | None, default None Price column name timestamp_col : str | None, default None @@ -359,6 +408,14 @@ def calendar_aware_labels( Grouping column(s) for panel-aware session labeling. contract : DataContractConfig | None, default None Optional shared dataframe contract. Used after config and before defaults. + feed_spec : FeedSpec or Mapping, optional + Shared column/time declarations, otherwise config.feed_spec. Named-calendar + feeds require timestamp semantics and a timezone for naive timestamps. + window_unit : WindowUnit or str, optional + Count windows default to expected_bar for shared feeds and omitted selectors; + a complete declared grid is required. Explicit observation counts and elapsed + duration horizons remain distinct. Legacy explicit selectors without FeedSpec + retain observation counts. Session-count windows are not implemented. Returns ------- @@ -402,8 +459,11 @@ def calendar_aware_labels( - Labels that would span session breaks are truncated at the break - This may result in more timeout labels near session closes - Rows outside an explicit calendar's trading times are rejected - - Naive datetimes passed to PandasMarketCalendar are interpreted as UTC - - For 24/7 markets, use standard triple_barrier_labels instead + - Without FeedSpec, legacy naive calendar timestamps are interpreted as UTC + - FeedSpec naive timestamps use its declared timezone; ambiguous times fail + - Explicit None preserves per-asset observation order and global row order; + label_time contains per-asset row offsets and label_duration is unset + - A named 24/7 schedule still segments daily sessions; it is not row mode """ from ml4t.engineer.config import LabelingConfig as _LabelingConfig @@ -414,6 +474,52 @@ def calendar_aware_labels( "use LabelingConfig.triple_barrier(...)." ) + declared_feed = feed_spec if feed_spec is not None else config.feed_spec + legacy_selector = declared_feed is None and calendar is not UNSET_CALENDAR + feed = ( + FeedSpec.from_any(declared_feed) + if declared_feed is not None + else FeedSpec(time_contract_version=2) + ) + if calendar is UNSET_CALENDAR and "calendar" in config.model_fields_set: + calendar = config.calendar + custom_calendar = ( + calendar is not UNSET_CALENDAR and calendar is not None and not isinstance(calendar, str) + ) + selected = calendar if custom_calendar else feed.resolve_calendar(calendar=calendar) + duration_horizon = isinstance(config.max_holding_period, timedelta) or ( + isinstance(config.max_holding_period, str) and is_duration_string(config.max_holding_period) + ) + declared_unit = window_unit if window_unit is not None else config.window_unit + unit = ( + WindowUnit(declared_unit) + if declared_unit is not None + else ( + WindowUnit.ELAPSED + if duration_horizon + else WindowUnit.OBSERVATION + if selected is None or legacy_selector + else WindowUnit.EXPECTED_BAR + ) + ) + if selected is None and (unit != WindowUnit.OBSERVATION or duration_horizon): + raise ValueError( + "calendar=None requires observation-count holding periods and window_unit='observation'" + ) + if unit == WindowUnit.SESSION: + raise ValueError("session-count labeling windows are not supported") + if (unit == WindowUnit.ELAPSED) != duration_horizon: + raise ValueError( + "elapsed windows require duration holding periods; count windows require observation/expected_bar" + ) + if declared_feed is not None and contract is None and config.data_contract is None: + from ml4t.engineer.config import DataContractConfig as _DataContractConfig + + contract = _DataContractConfig( + timestamp_col=feed.timestamp_col, + price_col=feed.price_col, + symbol_col=feed.entity_col, + ) resolved_price_col, resolved_ts_col, resolved_group_cols = resolve_labeling_columns( data=data, price_col=price_col, @@ -421,20 +527,53 @@ def calendar_aware_labels( group_col=group_col, config=config, contract=contract, - require_timestamp=True, + require_timestamp=selected is not None, + allow_temporal_contract=True, ) if config.method != "triple_barrier": raise ValueError("calendar_aware_labels requires LabelingConfig.method='triple_barrier'.") + if selected is None: + return _segment_labels(data, config, resolved_price_col, resolved_group_cols) + if feed.timestamp_semantics == TimestampSemantics.SESSION_LABEL: + raise ValueError("session_label timestamps are not intraday labeling instants") + if declared_feed is not None and feed.timestamp_semantics is None: + raise ValueError("Calendar-aware FeedSpec requires declared timestamp_semantics") + if declared_feed is not None and selected == "auto": + raise ValueError( + "auto gap inference is a legacy mode, not an authoritative FeedSpec calendar" + ) + assert resolved_ts_col is not None + dtype = data.schema[resolved_ts_col] + if ( + declared_feed is not None + and isinstance(dtype, pl.Datetime) + and dtype.time_zone is None + and feed.timezone is None + ): + raise ValueError("Calendar-aware FeedSpec requires timezone metadata for naive timestamps") + if unit == WindowUnit.EXPECTED_BAR: + if not isinstance(selected, str) or selected == "auto": + raise ValueError("expected_bar windows require an authoritative named calendar") + from ml4t.engineer.core.bar_grid import validate_declared_grid + + validate_declared_grid( + data, + feed_spec=feed, + calendar=selected, + timestamp_col=resolved_ts_col, + group_cols=resolved_group_cols, + ) + # Create calendar instance if string provided - if isinstance(calendar, str): - if calendar == "auto": + if isinstance(selected, str): + if selected == "auto": cal = SimpleTradingCalendar() else: # Always use pandas_market_calendars for string calendars - cal = PandasMarketCalendar(calendar) + cal = PandasMarketCalendar(selected) else: - cal = calendar + cal = selected session_col = _temporary_column_name(data.columns, "__ml4t_session_id") assert resolved_ts_col is not None @@ -471,6 +610,9 @@ def calendar_aware_labels( cal, resolved_ts_col, resolved_group_cols, + timezone=feed.timezone, + bar_close=feed.timestamp_semantics == TimestampSemantics.BAR_CLOSE + or feed.bar_label == "right", ) data_with_session = sorted_data.with_columns( pl.Series(session_col, session_ids, dtype=pl.Int64) @@ -479,12 +621,12 @@ def calendar_aware_labels( session_group_cols = ( [*resolved_group_cols, session_col] if resolved_group_cols else [session_col] ) - labeled = triple_barrier_labels( - data=data_with_session, - config=config, - price_col=resolved_price_col, - timestamp_col=resolved_ts_col, - group_col=session_group_cols, + labeled = _segment_labels( + data_with_session, + config, + resolved_price_col, + session_group_cols, + resolved_ts_col, ) return labeled.drop(session_col) diff --git a/src/ml4t/engineer/labeling/utils.py b/src/ml4t/engineer/labeling/utils.py index 7ae5a36..c494821 100644 --- a/src/ml4t/engineer/labeling/utils.py +++ b/src/ml4t/engineer/labeling/utils.py @@ -454,11 +454,25 @@ def resolve_labeling_columns( config: LabelingConfig | None = None, contract: DataContractConfig | None = None, require_timestamp: bool = False, + allow_temporal_contract: bool = False, ) -> tuple[str, str | None, list[str]]: """Resolve columns using explicit args > labeling config > shared contract > defaults.""" config_fields_set = ( set(getattr(config, "model_fields_set", set())) if config is not None else set() ) + if ( + config is not None + and not allow_temporal_contract + and ( + "calendar" in config_fields_set + or config.feed_spec is not None + or config.window_unit is not None + ) + ): + raise DataValidationError( + "Declared calendar/feed/window semantics require calendar_aware_labels; " + "this standalone kernel does not interpret the temporal contract" + ) timestamp_is_explicit = timestamp_col is not None if contract is None and config is not None: diff --git a/tests/test_labeling_time_contract.py b/tests/test_labeling_time_contract.py new file mode 100644 index 0000000..e584bf5 --- /dev/null +++ b/tests/test_labeling_time_contract.py @@ -0,0 +1,228 @@ +from copy import deepcopy +from datetime import UTC, datetime, timedelta + +import polars as pl +import pytest +from ml4t.specs import FeedSpec +from polars.testing import assert_frame_equal + +from ml4t.engineer.config import LabelingConfig +from ml4t.engineer.core.exceptions import DataValidationError +from ml4t.engineer.labeling import calendar_aware_labels, triple_barrier_labels + + +def _config(**kwargs): + return LabelingConfig.triple_barrier( + upper_barrier=0.5, lower_barrier=0.5, max_holding_period=1, **kwargs + ) + + +def _prices(timestamps=None): + values = {"row": [0, 1, 2, 3], "close": [100.0, 101.0, 99.0, 102.0]} + if timestamps is not None: + values["timestamp"] = timestamps + return pl.DataFrame(values) + + +@pytest.mark.parametrize( + "timestamps", + [ + None, + [datetime(2025, 7, 4) + timedelta(hours=i) for i in range(4)], + [datetime(2025, 7, day) for day in (14, 4, 31, 7)], + ], +) +def test_explicit_none_labels_ordered_observations_without_loading_calendar( + monkeypatch, timestamps +): + import sys + + monkeypatch.setitem(sys.modules, "pandas_market_calendars", None) + result = calendar_aware_labels(_prices(timestamps), _config(), calendar=None) + assert result["row"].to_list() == [0, 1, 2, 3] + assert result["label_return"].to_list() == pytest.approx([0.01, -2 / 101, 3 / 99, 0.0]) + assert result["label_bars"].to_list() == [1, 1, 1, 0] + + +def test_row_mode_keeps_interleaved_asset_order_and_scans_each_asset_independently(): + data = pl.DataFrame( + { + "symbol": ["A", "B", "A", "B"], + "close": [100.0, 200.0, 101.0, 198.0], + "timestamp": [datetime(2025, 7, d) for d in (14, 11, 4, 7)], + "row": [0, 1, 2, 3], + } + ) + result = calendar_aware_labels(data, _config(), calendar=None) + assert result["row"].to_list() == [0, 1, 2, 3] + assert result["label_return"].to_list() == pytest.approx([0.01, -0.01, 0.0, 0.0]) + + +@pytest.mark.parametrize("suffix", ["json", "yaml"]) +@pytest.mark.parametrize( + "declaration,expected", + [({}, "NYSE"), ({"calendar": None}, None), ({"calendar": "24/7"}, "24/7")], +) +def test_real_config_roundtrip_preserves_omitted_named_and_disabled_calendar( + tmp_path, suffix, declaration, expected +): + config = _config(**declaration) + path = tmp_path / f"labeling.{suffix}" + getattr(config, f"to_{suffix}")(path) + loaded = getattr(LabelingConfig, f"from_{suffix}")(path) + copied = deepcopy(loaded).model_copy() + assert ("calendar" in copied.to_dict()) == ("calendar" in declaration) + saturday = pl.DataFrame({"timestamp": [datetime(2025, 7, 5, 14, tzinfo=UTC)], "close": [100.0]}) + if expected == "NYSE": + with pytest.raises(DataValidationError, match="outside"): + calendar_aware_labels(saturday, copied, window_unit="observation") + else: + assert calendar_aware_labels(saturday, copied, window_unit="observation").height == 1 + + +def test_feed_metadata_precedence_and_declared_null_survive_nested_json_handoff(tmp_path): + feed = FeedSpec( + time_contract_version=2, + calendar=None, + price_col="mid", + timestamp_col="time", + entity_col=None, + ) + config = _config(feed_spec=feed) + path = tmp_path / "metadata.json" + config.to_json(path) + loaded = LabelingConfig.from_json(path) + data = pl.DataFrame( + {"mid": [100.0, 101.0], "time": [datetime(2025, 7, 5), datetime(2025, 7, 4)]} + ) + result = calendar_aware_labels(data, loaded) + assert result["label_return"].to_list() == pytest.approx([0.01, 0.0]) + assert loaded.to_dict()["feed_spec"]["calendar"] is None + with pytest.raises(DataValidationError, match="outside"): + calendar_aware_labels( + data, + loaded, + calendar="NYSE", + feed_spec=FeedSpec( + time_contract_version=2, + timezone="UTC", + timestamp_semantics="event_time", + price_col="mid", + timestamp_col="time", + entity_col=None, + ), + window_unit="observation", + ) + + +def test_explicit_argument_overrides_config_and_metadata_without_mutating_either(): + data = _prices([datetime(2025, 7, 5) + timedelta(hours=i) for i in range(4)]) + config = _config(calendar="NYSE", feed_spec=FeedSpec(time_contract_version=2, calendar="LSE")) + before = config.to_dict() + result = calendar_aware_labels(data, config, calendar=None) + assert result.height == 4 + assert config.to_dict() == before + assert_frame_equal(data, _prices([datetime(2025, 7, 5) + timedelta(hours=i) for i in range(4)])) + + +def test_named_schedule_uses_declared_naive_timezone_instead_of_legacy_utc(): + data = pl.DataFrame( + { + "timestamp": [datetime(2025, 7, 7, 9, 30), datetime(2025, 7, 7, 10)], + "close": [100.0, 101.0], + } + ) + feed = FeedSpec( + time_contract_version=2, + calendar="NYSE", + timezone="America/New_York", + timestamp_semantics="event_time", + ) + result = calendar_aware_labels(data, _config(), feed_spec=feed, window_unit="observation") + assert result["label_return"].to_list() == pytest.approx([0.01, 0.0]) + assert_frame_equal(result.select(data.columns), data) + + +@pytest.mark.parametrize("unit", ["session", "elapsed", "expected_bar"]) +def test_disabled_calendar_rejects_non_observation_units(unit): + with pytest.raises(ValueError, match="observation"): + calendar_aware_labels(_prices(), _config(), calendar=None, window_unit=unit) + + +def test_expected_bar_labels_reject_price_outage_before_horizon_interpretation(): + feed = FeedSpec( + time_contract_version=2, + calendar="24/7", + timezone="UTC", + data_frequency="1h", + timestamp_semantics="bar_close", + bar_type="time", + bar_grid_anchor="session_open", + bar_label="right", + partial_bar_policy="include", + ) + complete = pl.DataFrame( + { + "timestamp": [datetime(2025, 7, 7, i, tzinfo=UTC) for i in range(1, 5)], + "close": [100.0, 101.0, 99.0, 102.0], + } + ) + assert ( + calendar_aware_labels( + complete, _config(), feed_spec=feed, window_unit="expected_bar" + ).height + == 4 + ) + outage = complete.filter(pl.col("timestamp").dt.hour() != 2) + with pytest.raises(ValueError, match="missing scheduled bar"): + calendar_aware_labels(outage, _config(), feed_spec=feed, window_unit="expected_bar") + + +@pytest.mark.parametrize( + "declaration,error", + [ + ({"timestamp_semantics": "session_label"}, "session_label"), + ({"timestamp_semantics": "event_time"}, "timezone"), + ], +) +def test_named_schedule_rejects_ambiguous_timestamp_declaration_atomically(declaration, error): + data = _prices([datetime(2025, 7, 7, 14) + timedelta(minutes=i) for i in range(4)]) + original = data.clone() + with pytest.raises(ValueError, match=error): + calendar_aware_labels( + data, _config(), feed_spec=FeedSpec(time_contract_version=2, **declaration) + ) + assert_frame_equal(data, original) + + +def test_close_labeled_price_at_lunch_break_is_valid_and_does_not_cross_the_break(): + data = pl.DataFrame( + { + "timestamp": [ + datetime(2025, 7, 7, h, m, tzinfo=UTC) for h, m in [(3, 30), (4, 0), (5, 30)] + ], + "close": [100.0, 101.0, 110.0], + } + ) + feed = FeedSpec(time_contract_version=2, calendar="HKEX", timestamp_semantics="bar_close") + result = calendar_aware_labels(data, _config(), feed_spec=feed, window_unit="observation") + assert result["label_return"].to_list() == pytest.approx([0.01, 0.0, 0.0]) + assert_frame_equal(result.select(data.columns), data) + + +def test_named_feed_does_not_infer_timestamp_meaning_from_aware_values(): + data = _prices([datetime(2025, 7, 7, 14, tzinfo=UTC) + timedelta(minutes=i) for i in range(4)]) + with pytest.raises(ValueError, match="timestamp_semantics"): + calendar_aware_labels(data, _config(), feed_spec=FeedSpec(time_contract_version=2)) + + +def test_legacy_kernel_does_not_silently_ignore_a_declared_calendar_opt_out(): + data = _prices([datetime(2025, 7, day) for day in (14, 4, 31, 7)]) + with pytest.raises(DataValidationError, match="calendar_aware_labels"): + triple_barrier_labels(data, _config(calendar=None)) + + +def test_new_default_does_not_treat_an_undeclared_intraday_grid_as_complete(): + data = _prices([datetime(2025, 7, 7, 14, tzinfo=UTC) + timedelta(hours=i) for i in range(4)]) + with pytest.raises(ValueError, match="declared bar grid"): + calendar_aware_labels(data, _config()) From 733ee270a7ee7d9ba9d487c368679c64fa6be392 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:33:25 -0400 Subject: [PATCH 02/28] docs: align feature task guidance with declared calendar modes (#94) --- src/ml4t/engineer/features/AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ml4t/engineer/features/AGENTS.md b/src/ml4t/engineer/features/AGENTS.md index 85ecb6f..14c43d8 100644 --- a/src/ml4t/engineer/features/AGENTS.md +++ b/src/ml4t/engineer/features/AGENTS.md @@ -55,9 +55,16 @@ result = compute_features( {"name": "sma", "params": {"period": 50}}, {"name": "bollinger_bands", "params": {"period": 20, "std_dev": 2.5}}, ], + calendar=None, ) ``` +This example explicitly counts observation rows. Omitting `calendar` selects shared +feed metadata, then NYSE, and requires a declared complete intraday grid. Use +`FeedSpec` declarations for expected bars; do not infer frequency or holidays from gaps. +Numerical kernels keep their documented annualization conventions independently of +calendar selection. Session and elapsed feature windows remain unsupported. + ## Trust Signals - 120 total features across 11 categories From 578496316469467818689644e256959267fb5167 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:39:51 -0400 Subject: [PATCH 03/28] fix: retain calendar declaration presence in labeling config equality (#94) --- src/ml4t/engineer/config/labeling.py | 5 +++++ tests/test_labeling_time_contract.py | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/src/ml4t/engineer/config/labeling.py b/src/ml4t/engineer/config/labeling.py index fce13f2..299ba7d 100644 --- a/src/ml4t/engineer/config/labeling.py +++ b/src/ml4t/engineer/config/labeling.py @@ -165,6 +165,11 @@ class LabelingConfig(BaseConfig): description="Observation, expected_bar, session or elapsed; consumer validates support", ) + def __eq__(self, other: object) -> bool: + if not isinstance(other, LabelingConfig): + return NotImplemented + return self.to_dict() == other.to_dict() + @field_validator("calendar", mode="before") @classmethod def validate_calendar(cls, value: Any) -> str | None: diff --git a/tests/test_labeling_time_contract.py b/tests/test_labeling_time_contract.py index e584bf5..3b83180 100644 --- a/tests/test_labeling_time_contract.py +++ b/tests/test_labeling_time_contract.py @@ -226,3 +226,12 @@ def test_new_default_does_not_treat_an_undeclared_intraday_grid_as_complete(): data = _prices([datetime(2025, 7, 7, 14, tzinfo=UTC) + timedelta(hours=i) for i in range(4)]) with pytest.raises(ValueError, match="declared bar grid"): calendar_aware_labels(data, _config()) + + +def test_config_equality_does_not_collapse_disabled_and_undeclared_calendars(): + inherited = _config() + disabled = inherited.model_copy(update={"calendar": None}) + assert disabled != inherited + assert disabled == deepcopy(disabled) + assert disabled.to_dict()["calendar"] is None + assert "calendar" not in inherited.to_dict() From 565a271ae5df5234d52186275ba6546bec677041 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:43:42 -0400 Subject: [PATCH 04/28] fix(api): accept timestamp-free observation rows in explicit row mode (#76) --- docs/user-guide/features.md | 7 ++++--- src/ml4t/engineer/api.py | 14 ++++++++------ tests/test_api.py | 8 ++++---- tests/test_api_bar_grid.py | 23 +++++++++++++++++++++++ 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/docs/user-guide/features.md b/docs/user-guide/features.md index a0d9477..ea4d8b8 100644 --- a/docs/user-guide/features.md +++ b/docs/user-guide/features.md @@ -69,8 +69,9 @@ Common asset columns such as `asset_id`, `symbol`, and `ticker` are detected aut and every rolling or lagged feature is evaluated independently for each asset. The examples explicitly select `calendar=None`: their periods count observations and input order is preserved, even when timestamps are irregular. Sort prices yourself before -deriving returns if chronological order is intended. Without timestamps, also pass -`assume_sorted=True` to assert that the supplied row order is correct. +deriving returns if chronological order is intended. Row mode does not require timestamps +or an additional `assume_sorted=True` assertion. A disabled calendar in shared feed metadata +has the same behavior, even when the feed's timestamp column is absent. Use `output` when requesting more than one configuration of the same feature: @@ -469,7 +470,7 @@ Most features expect a DataFrame with standardized column names (lowercase): | Column | Type | Required By | |--------|------|-------------| -| `timestamp` | datetime | Temporal ordering, unless `assume_sorted=True` | +| `timestamp` | datetime | Required for calendar-aware grids; optional in observation-row mode | | `asset_id` | string | Optional panel grouping, auto-detected when present | | `open` | float | OHLCV, OHLC features | | `high` | float | OHLCV, OHLC, HLC, HL features | diff --git a/src/ml4t/engineer/api.py b/src/ml4t/engineer/api.py index 8aed198..f8792b7 100644 --- a/src/ml4t/engineer/api.py +++ b/src/ml4t/engineer/api.py @@ -112,9 +112,9 @@ def compute_features( Calendar-aware input is sorted by group and timestamp. Row mode retains input order within every group. assume_sorted : bool, default False - Permit row-order execution when no timestamp column exists. This is an - explicit assertion by the caller that rows are already in the required - temporal order. + Retained legacy ordering argument. Explicit calendar=None already selects + input observation order and needs no timestamp or additional sort assertion. + Calendar-aware execution always requires a declared datetime grid. calendar : str | None, optional Explicit selection overrides declared feed metadata, then defaults to NYSE. Explicit None selects observation rows in input order, without schedule checks. @@ -182,7 +182,7 @@ def compute_features( - Calendar-aware calls require a declared complete grid before any feature runs. Empty feature lists can validate and sort prices before returns are derived. - Explicit ``calendar=None`` preserves input row order without schedule checks. - Without a timestamp, callers must still set ``assume_sorted=True`` explicitly. + Row mode does not require a timestamp or an additional sort assertion. """ from ml4t.engineer.core.bar_grid import validate_feature_grid from ml4t.engineer.core.schemas import validate_ohlcv_schema @@ -218,7 +218,9 @@ def compute_features( return data if feed_spec is not None: - if timestamp_col is None: + if timestamp_col is None and ( + resolved_calendar is not None or contract.timestamp_col in schema + ): timestamp_col = contract.timestamp_col if group_col is None and contract.entity_col is not None: group_col = ( @@ -231,7 +233,7 @@ def compute_features( data=data, group_col=group_col, timestamp_col=timestamp_col, - assume_sorted=assume_sorted, + assume_sorted=assume_sorted or resolved_calendar is None, ) # Resolve dependencies and get execution order diff --git a/tests/test_api.py b/tests/test_api.py index aa9a5d2..56c3bfd 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -317,17 +317,17 @@ def test_output_cannot_replace_input_column(sample_ohlcv_data): ) -def test_compute_requires_time_or_explicit_order_assumption(sample_ohlcv_data): - """Row-order semantics must be explicit when no time column exists.""" +def test_explicit_row_mode_needs_no_additional_temporal_order_assertion(sample_ohlcv_data): + """Selecting observation order is sufficient even without timestamp metadata.""" data = sample_ohlcv_data.drop("timestamp") - with pytest.raises(DataSchemaError, match="No time column found"): - compute_features(data, [{"name": "sma", "params": {"period": 2}}], calendar=None) + rows = compute_features(data, [{"name": "sma", "params": {"period": 2}}], calendar=None) result = compute_features( data, [{"name": "sma", "params": {"period": 2}}], assume_sorted=True, calendar=None ) assert "sma" in result.columns + assert rows.equals(result) def test_row_mode_isolates_assets_without_reordering_observations(): diff --git a/tests/test_api_bar_grid.py b/tests/test_api_bar_grid.py index 0875fda..897dbfe 100644 --- a/tests/test_api_bar_grid.py +++ b/tests/test_api_bar_grid.py @@ -144,6 +144,29 @@ def test_row_mode_does_not_reorder_values_when_timestamp_order_changes(lazy): assert outputs[0].equals(outputs[1]) +@pytest.mark.parametrize("lazy", [False, True]) +@pytest.mark.parametrize("metadata_disable", [False, True]) +def test_row_mode_does_not_require_timestamps_or_a_temporal_sort_assertion( + monkeypatch, lazy, metadata_disable +): + import sys + + monkeypatch.setitem(sys.modules, "pandas_market_calendars", None) + start = datetime(2025, 7, 11, tzinfo=UTC) + timed = returns(prices([start + timedelta(hours=h) for h in [200, 0, 73, 1, 2, 300, 7, 5]])) + rows = timed.drop("timestamp") + kwargs = ( + {"feed_spec": feed("NYSE").with_overrides(calendar=None)} + if metadata_disable + else {"calendar": None} + ) + output = run(rows, lazy, **kwargs) + control = run(timed, lazy, calendar=None) + assert output.height == rows.height + assert output.select(rows.columns).equals(rows) + assert output["realized_volatility"].equals(control["realized_volatility"]) + + @pytest.mark.parametrize("suffix", [".json", ".yaml"]) @pytest.mark.parametrize("calendar", ["NYSE", None, UNSET_CALENDAR]) def test_serialized_artifact_calendar_intent_reaches_real_feature_workflow( From 1d624da40b80457ada34255a70d93bb9a9265bd2 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:47:06 -0400 Subject: [PATCH 05/28] fix: use canonical calendar selector on the shared schedule adapter (#94) --- src/ml4t/engineer/core/calendars/__init__.py | 11 ++++--- src/ml4t/engineer/labeling/calendar.py | 31 ++++++++++++++++---- tests/test_labeling_time_contract.py | 11 +++++++ 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/ml4t/engineer/core/calendars/__init__.py b/src/ml4t/engineer/core/calendars/__init__.py index 8bb72b0..7fb8941 100644 --- a/src/ml4t/engineer/core/calendars/__init__.py +++ b/src/ml4t/engineer/core/calendars/__init__.py @@ -1,11 +1,10 @@ """ -Exchange calendars for time-aware feature engineering. +Exchange schedule helpers for membership, boundaries and dataframe annotations. -This module provides calendar-aware functionality for: -- Rolling windows over trading sessions (not calendar days) -- Holiday-aware feature calculations -- Market-hours filtering -- Cross-validation split timing +These helpers do not implement session-count feature windows or cross-validation. +CryptoCalendar retains legacy hourly-point enumeration; it is not an authoritative +session-label or shared WindowUnit engine. Shared calendar/grid consumers use +labeling.calendar.PandasMarketCalendar and FeedSpec declarations. """ from .base import ExchangeCalendar diff --git a/src/ml4t/engineer/labeling/calendar.py b/src/ml4t/engineer/labeling/calendar.py index 14db9f2..b0a2945 100644 --- a/src/ml4t/engineer/labeling/calendar.py +++ b/src/ml4t/engineer/labeling/calendar.py @@ -114,14 +114,32 @@ class PandasMarketCalendar: Supports 200+ calendars including CME, NYSE, LSE, etc. """ - def __init__(self, calendar_name: str): + def __init__( + self, + calendar_name: str | CalendarUnset = UNSET_CALENDAR, + *, + calendar: str | None | CalendarUnset = UNSET_CALENDAR, + ): """ Parameters ---------- - calendar_name : str - Calendar name (e.g., "CME_Equity", "NYSE", "LSE") - See pandas_market_calendars.get_calendar_names() + calendar_name : str, optional + Legacy positional/keyword selector. Conflicting selectors fail. + calendar : str or None, optional + Canonical named selector, default NYSE. None is rejected because a + provider adapter cannot represent calendar-free observation order. """ + if ( + calendar_name is not UNSET_CALENDAR + and calendar is not UNSET_CALENDAR + and calendar_name != calendar + ): + raise ValueError("calendar and calendar_name selectors conflict") + selected = FeedSpec(time_contract_version=2).resolve_calendar( + calendar=calendar_name if calendar is UNSET_CALENDAR else calendar + ) + if selected is None: + raise ValueError("PandasMarketCalendar requires a named calendar, not calendar=None") try: import pandas_market_calendars as mcal except ImportError as err: @@ -130,8 +148,9 @@ def __init__(self, calendar_name: str): "Install with: pip install pandas-market-calendars" ) from err - self.calendar_name = calendar_name - self._calendar = mcal.get_calendar(calendar_name) + self.calendar = selected + self.calendar_name = selected + self._calendar = mcal.get_calendar(selected) self._supports_interruptions = ( "interruptions" in signature(self._calendar.schedule).parameters ) diff --git a/tests/test_labeling_time_contract.py b/tests/test_labeling_time_contract.py index 3b83180..2a6c4a5 100644 --- a/tests/test_labeling_time_contract.py +++ b/tests/test_labeling_time_contract.py @@ -9,6 +9,7 @@ from ml4t.engineer.config import LabelingConfig from ml4t.engineer.core.exceptions import DataValidationError from ml4t.engineer.labeling import calendar_aware_labels, triple_barrier_labels +from ml4t.engineer.labeling.calendar import PandasMarketCalendar def _config(**kwargs): @@ -235,3 +236,13 @@ def test_config_equality_does_not_collapse_disabled_and_undeclared_calendars(): assert disabled == deepcopy(disabled) assert disabled.to_dict()["calendar"] is None assert "calendar" not in inherited.to_dict() + + +def test_schedule_adapter_canonical_calendar_preserves_legacy_selector_and_rejects_conflicts(): + assert PandasMarketCalendar(calendar="LSE").calendar_name == "LSE" + assert PandasMarketCalendar(calendar_name="LSE", calendar="LSE").calendar == "LSE" + assert PandasMarketCalendar().calendar == "NYSE" + with pytest.raises(ValueError, match="conflict"): + PandasMarketCalendar(calendar_name="NYSE", calendar="LSE") + with pytest.raises(ValueError, match="named calendar"): + PandasMarketCalendar(calendar=None) From 665d824599d5a0de46bdeec918ac5c4080f6e441 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:56:28 -0400 Subject: [PATCH 06/28] fix: preserve timestamp precision and sparse-event label schemas (#94) --- docs/user-guide/labeling.md | 3 ++ src/ml4t/engineer/labeling/triple_barrier.py | 29 ++++++++--- tests/test_labeling_time_contract.py | 55 +++++++++++++++++++- 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/docs/user-guide/labeling.md b/docs/user-guide/labeling.md index bdfeb46..3e550f5 100644 --- a/docs/user-guide/labeling.md +++ b/docs/user-guide/labeling.md @@ -486,6 +486,9 @@ definitions and chronological sorting; they do not acquire calendar interpretati timestamps. Use this wrapper when selecting the shared calendar/row contract. Standalone kernels reject configs declaring calendar/feed/window semantics instead of silently ignoring those declarations. +Their legacy `Date` inputs retain date-valued label times and calendar-day differences +in `label_duration`; those dates do not become authoritative session identifiers or +intraday instants. Shared-feed session labels remain explicitly rejected by this wrapper. ## Sample Weighting diff --git a/src/ml4t/engineer/labeling/triple_barrier.py b/src/ml4t/engineer/labeling/triple_barrier.py index 68c28b0..f9ff27e 100644 --- a/src/ml4t/engineer/labeling/triple_barrier.py +++ b/src/ml4t/engineer/labeling/triple_barrier.py @@ -263,19 +263,20 @@ def _calculate_time_durations( event_indices: npt.NDArray[np.intp], label_indices: npt.NDArray[np.int64], timestamp_col: str | None, -) -> tuple[list[Any], npt.NDArray[Any]]: +) -> tuple[pl.Series, npt.NDArray[Any]]: """Calculate label times and durations.""" n_events = len(event_indices) if timestamp_col is not None: - timestamps = data[timestamp_col].to_list() - label_times = [timestamps[idx] if idx < len(timestamps) else None for idx in label_indices] + label_times = data[timestamp_col].gather(label_indices) timestamps_array = data[timestamp_col].to_numpy() + if data.schema[timestamp_col] == pl.Date: + timestamps_array = timestamps_array.astype("datetime64[ms]") entry_times = timestamps_array[event_indices] exit_times = timestamps_array[label_indices] time_durations = exit_times - entry_times else: - label_times = list(label_indices) + label_times = pl.Series(label_indices) time_durations = np.array([None] * n_events, dtype=object) return label_times, time_durations @@ -285,7 +286,7 @@ def _build_labeling_result( data: pl.DataFrame, event_indices: npt.NDArray[np.intp], labels: npt.NDArray[np.int32], - label_times: list[Any], + label_times: pl.Series, label_prices: npt.NDArray[np.float64], label_returns: npt.NDArray[np.float64], bar_durations: npt.NDArray[np.int64], @@ -358,15 +359,27 @@ def _triple_barrier_labels_single_group( event_mask = data["event_time"].is_not_null() event_indices = np.where(event_mask.to_numpy())[0] if len(event_indices) == 0: - return data.with_columns( + time_dtype = data.schema[timestamp_col] if timestamp_col is not None else pl.Int64 + duration_dtype = ( + pl.Duration(time_dtype.time_unit if isinstance(time_dtype, pl.Datetime) else "ms") + if timestamp_col is not None + else pl.Object + ) + result = data.with_columns( label=pl.lit(None, dtype=pl.Int32), - label_time=pl.lit(None, dtype=pl.Int64), + label_time=pl.lit(None, dtype=time_dtype), label_price=pl.lit(None, dtype=pl.Float64), label_return=pl.lit(None, dtype=pl.Float64), label_bars=pl.lit(None, dtype=pl.Int64), - label_duration=pl.lit(None, dtype=pl.Utf8), + label_duration=pl.Series([None] * data.height, dtype=duration_dtype), barrier_hit=pl.lit(None, dtype=pl.Utf8), ) + if calculate_uniqueness: + result = result.with_columns( + label_uniqueness=pl.lit(None, dtype=pl.Float64), + sample_weight=pl.lit(None, dtype=pl.Float64), + ) + return result else: event_indices = np.arange(len(data)) diff --git a/tests/test_labeling_time_contract.py b/tests/test_labeling_time_contract.py index 2a6c4a5..3db21b3 100644 --- a/tests/test_labeling_time_contract.py +++ b/tests/test_labeling_time_contract.py @@ -1,5 +1,5 @@ from copy import deepcopy -from datetime import UTC, datetime, timedelta +from datetime import UTC, date, datetime, timedelta import polars as pl import pytest @@ -246,3 +246,56 @@ def test_schedule_adapter_canonical_calendar_preserves_legacy_selector_and_rejec PandasMarketCalendar(calendar_name="NYSE", calendar="LSE") with pytest.raises(ValueError, match="named calendar"): PandasMarketCalendar(calendar=None) + + +@pytest.mark.parametrize("precision", ["us", "ns"]) +@pytest.mark.parametrize("workflow", ["calendar", "row", "standalone"]) +def test_asset_without_selected_events_keeps_compatible_null_label_schema(precision, workflow): + data = pl.DataFrame( + { + "symbol": ["A", "B", "A", "B"], + "timestamp": [datetime(2025, 7, 7, hour, tzinfo=UTC) for hour in (14, 14, 15, 15)], + "close": [100.0, 200.0, 101.0, 201.0], + } + ).with_columns(pl.col("timestamp").cast(pl.Datetime(precision, "UTC"))) + selected = data.with_columns( + pl.when(pl.col("symbol") == "A") + .then(pl.col("timestamp")) + .otherwise(None) + .alias("event_time") + ) + if workflow == "standalone": + result = triple_barrier_labels(selected, _config(), calculate_uniqueness=True) + assert result.filter(pl.col("symbol") == "B")["sample_weight"].is_null().all() + else: + result = calendar_aware_labels( + selected, _config(), calendar=None if workflow == "row" else "NYSE" + ) + assert result.height == data.height + inactive = result.filter(pl.col("symbol") == "B") + assert inactive["label_time"].is_null().all() + assert inactive["label_duration"].is_null().all() + assert result.filter(pl.col("symbol") == "A")["label_return"].to_list() == pytest.approx( + [0.01, 0.0] + ) + assert result["label_time"].dtype == ( + pl.Int64 if workflow == "row" else data["timestamp"].dtype + ) + + +def test_legacy_date_labels_report_calendar_day_difference_without_numpy_resolution_error(): + data = pl.DataFrame( + { + "timestamp": [date(2025, 7, day) for day in (3, 7, 8)], + "close": [100.0, 101.0, 99.0], + } + ) + result = triple_barrier_labels(data, _config()) + assert result["label_time"].dtype == pl.Date + assert result["label_time"].to_list() == [date(2025, 7, 7), date(2025, 7, 8), date(2025, 7, 8)] + assert result["label_duration"].to_list() == [ + timedelta(days=4), + timedelta(days=1), + timedelta(0), + ] + assert result["label_bars"].to_list() == [1, 1, 0] From 9b635b54149b9ca217e35a682daba64ec3ef66b0 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 11:04:38 -0400 Subject: [PATCH 07/28] fix: keep row label duration metadata portable (#94) --- src/ml4t/engineer/labeling/calendar.py | 5 ++++- tests/test_labeling_time_contract.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/ml4t/engineer/labeling/calendar.py b/src/ml4t/engineer/labeling/calendar.py index b0a2945..3af122a 100644 --- a/src/ml4t/engineer/labeling/calendar.py +++ b/src/ml4t/engineer/labeling/calendar.py @@ -389,7 +389,10 @@ def _segment_labels( ) for frame in frames ] - return pl.concat(results, how="vertical").sort(row_col).drop(row_col) + result = pl.concat(results, how="vertical").sort(row_col).drop(row_col) + if timestamp_col is None: + result = result.with_columns(pl.lit(None).alias("label_duration")) + return result def calendar_aware_labels( diff --git a/tests/test_labeling_time_contract.py b/tests/test_labeling_time_contract.py index 3db21b3..9c2f3ee 100644 --- a/tests/test_labeling_time_contract.py +++ b/tests/test_labeling_time_contract.py @@ -1,5 +1,6 @@ from copy import deepcopy from datetime import UTC, date, datetime, timedelta +from io import BytesIO import polars as pl import pytest @@ -299,3 +300,12 @@ def test_legacy_date_labels_report_calendar_day_difference_without_numpy_resolut timedelta(0), ] assert result["label_bars"].to_list() == [1, 1, 0] + + +def test_row_labels_persist_without_untyped_python_object_duration_metadata(): + result = calendar_aware_labels(_prices(), _config(), calendar=None) + target = BytesIO() + result.write_parquet(target) + target.seek(0) + assert_frame_equal(pl.read_parquet(target), result) + assert result["label_duration"].is_null().all() From 49471ff6e35c1dd352ce1d44684c21469c797f63 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 11:16:49 -0400 Subject: [PATCH 08/28] fix: honor explicit labeling serialization projections (#94) --- src/ml4t/engineer/config/labeling.py | 12 ++++++++++-- tests/test_labeling_time_contract.py | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/ml4t/engineer/config/labeling.py b/src/ml4t/engineer/config/labeling.py index 299ba7d..b8197f2 100644 --- a/src/ml4t/engineer/config/labeling.py +++ b/src/ml4t/engineer/config/labeling.py @@ -32,6 +32,7 @@ from pydantic import ( AliasChoices, Field, + SerializationInfo, SerializerFunctionWrapHandler, field_serializer, field_validator, @@ -181,12 +182,19 @@ def validate_feed_spec(cls, value: Any) -> dict[str, Any] | None: return None if value is None else FeedSpec.from_any(value).to_dict() @model_serializer(mode="wrap") - def serialize_declarations(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + def serialize_declarations( + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo + ) -> dict[str, Any]: payload = handler(self) for name in ("calendar", "price_col", "timestamp_col", "group_col", "data_contract"): if name not in self.model_fields_set: payload.pop(name, None) - if "calendar" in self.model_fields_set and self.calendar is None: + if ( + "calendar" in self.model_fields_set + and self.calendar is None + and (info.include is None or "calendar" in info.include) + and (info.exclude is None or "calendar" not in info.exclude) + ): payload["calendar"] = None return payload diff --git a/tests/test_labeling_time_contract.py b/tests/test_labeling_time_contract.py index 9c2f3ee..7a85493 100644 --- a/tests/test_labeling_time_contract.py +++ b/tests/test_labeling_time_contract.py @@ -239,6 +239,14 @@ def test_config_equality_does_not_collapse_disabled_and_undeclared_calendars(): assert "calendar" not in inherited.to_dict() +@pytest.mark.parametrize("mode", ["python", "json"]) +def test_declared_null_calendar_does_not_leak_into_explicit_serialization_projection(mode): + config = _config(calendar=None) + assert config.model_dump(mode=mode, include={"method"}) == {"method": "triple_barrier"} + assert "calendar" not in config.model_dump(mode=mode, exclude={"calendar"}, exclude_none=True) + assert config.to_dict(mode=mode, exclude_none=True)["calendar"] is None + + def test_schedule_adapter_canonical_calendar_preserves_legacy_selector_and_rejects_conflicts(): assert PandasMarketCalendar(calendar="LSE").calendar_name == "LSE" assert PandasMarketCalendar(calendar_name="LSE", calendar="LSE").calendar == "LSE" From e64dde8c6b49ee200bd35783f93bd7ae76804313 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 15:17:56 -0400 Subject: [PATCH 09/28] ci: run one full wheel suite and prove failure propagation (#99) --- .github/workflows/ci.yml | 9 +-- tests/test_ci_invocation.py | 107 +++++++++++++++++++++++++++++++++++ tests/test_release_policy.py | 10 +--- 3 files changed, 113 insertions(+), 13 deletions(-) create mode 100644 tests/test_ci_invocation.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c19be9..b62c926 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,14 +218,11 @@ jobs: run: >- uv run --python .artifact-venv --no-project ty check --python-version ${{ matrix.python-version }} - - name: Run the installed-wheel suite repeatedly + - name: Run the installed-wheel suite shell: bash run: | - for iteration in {1..10}; do - echo "Installed-wheel suite ${iteration}/10" - uv run --python .artifact-venv --no-project \ - python -m pytest tests/ -q --tb=short --no-cov - done + uv run --python .artifact-venv --no-project \ + python -m pytest tests/ -q --tb=short --no-cov - name: Run documented workflows from the installed candidate run: >- uv run --python .artifact-venv --no-project diff --git a/tests/test_ci_invocation.py b/tests/test_ci_invocation.py new file mode 100644 index 0000000..86ec4da --- /dev/null +++ b/tests/test_ci_invocation.py @@ -0,0 +1,107 @@ +"""Execute the configured installed-suite command, including abnormal exits.""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).parents[1] + + +def _run_suite(tmp_path: Path, test: str, *, hook: str = "", options: str = ""): + workflow = yaml.load((ROOT / ".github/workflows/ci.yml").read_text(), Loader=yaml.BaseLoader) + command = next( + step["run"] + for step in workflow["jobs"]["test"]["steps"] + if "python -m pytest tests/" in step.get("run", "") + ) + # Bind the runner's interpreter input, without replacing its shell/exit logic. + command = command.replace("--python .artifact-venv", f"--python {shlex.quote(sys.executable)}") + (tmp_path / "tests").mkdir() + (tmp_path / "tests/test_case.py").write_text(test) + (tmp_path / "pytest.ini").write_text(f"[pytest]\naddopts = {options}\n") + (tmp_path / "conftest.py").write_text( + "import os\nfrom pathlib import Path\n" + "def pytest_sessionstart(session):\n" + " with Path('sessions').open('a') as stream:\n" + " stream.write('started\\n')\n" + " if os.name == 'posix':\n" + " import resource\n" + " resource.setrlimit(resource.RLIMIT_CORE, (0, 0))\n" + hook + ) + return subprocess.run( + ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", command], + cwd=tmp_path, + env={ + **os.environ, + "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1", + "PYTEST_PLUGINS": "pytest_cov.plugin,pytest_timeout", + "PYTEST_ADDOPTS": "", + }, + capture_output=True, + text=True, + timeout=45, + check=False, + ) + + +def test_routine_wheel_cell_runs_the_full_suite_once(tmp_path: Path) -> None: + result = _run_suite(tmp_path, "def test_pass():\n assert 2 + 2 == 4\n") + assert result.returncode == 0, result.stdout + result.stderr + assert (tmp_path / "sessions").read_text().splitlines() == ["started"] + + +@pytest.mark.parametrize( + ("test", "hook", "options", "message"), + [ + ("def test_fails():\n assert False, 'real-test-failure'\n", "", "", "real-test-failure"), + ("def test_broken(:\n", "", "", "SyntaxError"), + ("VALUE = 1\n", "", "", "no tests ran"), + ("def test_pass(): pass\n", "", "--not-a-real-pytest-option", "unrecognized arguments"), + ( + "def test_pass(): pass\n", + "def pytest_collection(session):\n raise RuntimeError('real-internal-error')\n", + "", + "real-internal-error", + ), + ("def test_crashes():\n import os\n os.abort()\n", "", "", ""), + ( + "import time\nimport pytest\n@pytest.mark.timeout(0.2)\n" + "def test_times_out():\n time.sleep(30)\n", + "", + "", + "Timeout", + ), + ( + "def test_pass(): pass\n", + "def pytest_sessionfinish(session, exitstatus):\n" + " assert exitstatus == 0\n" + " print('1 passed', flush=True)\n" + " os.abort()\n", + "", + "1 passed", + ), + ], + ids=[ + "failure", + "collection", + "empty", + "usage", + "internal", + "crash", + "timeout", + "pass-then-crash", + ], +) +def test_configured_wheel_command_propagates_real_failure( + tmp_path: Path, test: str, hook: str, options: str, message: str +) -> None: + result = _run_suite(tmp_path, test, hook=hook, options=options) + assert result.returncode != 0, result.stdout + result.stderr + assert message in result.stdout + result.stderr diff --git a/tests/test_release_policy.py b/tests/test_release_policy.py index 07db42a..eef78f6 100644 --- a/tests/test_release_policy.py +++ b/tests/test_release_policy.py @@ -62,7 +62,7 @@ def test_each_matrix_cell_runs_all_release_checks_without_masking_failures() -> "Export installed-wheel test environment", "Install the candidate wheel in a clean environment", "Import the installed candidate", - "Run the installed-wheel suite repeatedly", + "Run the installed-wheel suite", "Run documented workflows from the installed candidate", } <= commands.keys() assert setup["with"] == {"python-version": "${{ matrix.python-version }}"} @@ -80,13 +80,9 @@ def test_each_matrix_cell_runs_all_release_checks_without_masking_failures() -> assert "--exclude" not in commands["Run ty check"] assert "--extra-search-path" not in commands["Run ty check"] - test_command = commands["Run the installed-wheel suite repeatedly"] - assert "for iteration in {1..10}" in test_command + test_command = commands["Run the installed-wheel suite"] assert "--python .artifact-venv --no-project" in test_command assert "python -m pytest tests/" in test_command - assert "set +e" not in test_command - assert "PYTEST_EXIT" not in test_command - assert "exit 0" not in test_command export_command = commands["Export installed-wheel test environment"] assert "--group dev --extra ta --extra store --extra viz" in export_command @@ -100,7 +96,7 @@ def test_each_matrix_cell_runs_all_release_checks_without_masking_failures() -> step_names = [step.get("name") for step in steps] assert step_names.index( "Install the candidate wheel in a clean environment" - ) < step_names.index("Run the installed-wheel suite repeatedly") + ) < step_names.index("Run the installed-wheel suite") def test_release_publishes_only_the_qualified_artifact() -> None: From 1fb082cf4888e7a995398b3a40163733c183d4ce Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 15:26:13 -0400 Subject: [PATCH 10/28] ci: verify installed identity and separate native fresh-process stress (#99) --- .github/workflows/ci.yml | 4 +- .github/workflows/native-stress.yml | 114 ++++++++++++++++++ scripts/ci/candidate.py | 60 ++++++++- scripts/ci/native_stress.py | 54 +++++++++ tests/features/test_rolling_map_first_call.py | 37 +++--- tests/test_ci_invocation.py | 52 +++++++- 6 files changed, 298 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/native-stress.yml create mode 100644 scripts/ci/native_stress.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b62c926..ca3c3ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,7 +213,9 @@ jobs: uv pip install --python .artifact-venv \ --requirements "${{ runner.temp }}/test-requirements.txt" - name: Import the installed candidate - run: uv run --python .artifact-venv --no-project python -c "import ml4t.engineer" + run: >- + uv run --python .artifact-venv --no-project + python scripts/ci/candidate.py verify-installed candidate --source-root src - name: Run ty check run: >- uv run --python .artifact-venv --no-project diff --git a/.github/workflows/native-stress.yml b/.github/workflows/native-stress.yml new file mode 100644 index 0000000..8896765 --- /dev/null +++ b/.github/workflows/native-stress.yml @@ -0,0 +1,114 @@ +name: Native stress + +on: + pull_request: + branches: [main] + paths: + - "src/ml4t/engineer/features/**" + - "src/ml4t/engineer/core/**" + - "tests/features/**" + - "tests/test_all_features_integration.py" + - "scripts/ci/**" + - ".github/workflows/ci.yml" + - ".github/workflows/native-stress.yml" + - "pyproject.toml" + - "uv.lock" + push: + branches: [main] + paths: + - "src/ml4t/engineer/features/**" + - "src/ml4t/engineer/core/**" + - "tests/features/**" + - "tests/test_all_features_integration.py" + - "scripts/ci/**" + - ".github/workflows/ci.yml" + - ".github/workflows/native-stress.yml" + - "pyproject.toml" + - "uv.lock" + schedule: + - cron: "17 5 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: native-stress-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 + with: + version: "0.10.9" + - run: uv sync --python 3.12 --dev + - run: uv build --out-dir native-candidate/dist + - name: Bind the stress artifact to the tested revision + run: >- + uv run python scripts/ci/candidate.py create native-candidate + --commit-sha "${{ github.sha }}" + --git-tree "$(git rev-parse 'HEAD^{tree}')" + - id: matrix + run: uv run python scripts/ci/native_stress.py --matrix >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 + with: + name: native-stress-candidate + path: native-candidate/ + if-no-files-found: error + + stress: + name: Native stress (${{ matrix.os }}, Python ${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + needs: build + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.build.outputs.matrix) }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 + with: + version: "0.10.9" + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 + with: + python-version: ${{ matrix.python-version }} + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 + with: + name: native-stress-candidate + path: native-candidate + - name: Verify stress artifact identity + shell: bash + run: >- + python scripts/ci/candidate.py verify native-candidate + --expected-commit "${{ github.sha }}" + --expected-tree "$(git rev-parse 'HEAD^{tree}')" + - name: Install the built wheel with locked test dependencies + shell: bash + run: | + uv export --locked --group dev --extra ta --extra store --extra viz \ + --no-emit-project --no-hashes --output-file "${{ runner.temp }}/stress-requirements.txt" + uv venv --python python .artifact-venv + wheel="$(find native-candidate/dist -maxdepth 1 -name '*.whl' -print -quit)" + UV_CACHE_DIR="${{ runner.temp }}/fresh-wheel-cache" \ + uv pip install --python .artifact-venv "$wheel" + uv pip install --python .artifact-venv \ + --requirements "${{ runner.temp }}/stress-requirements.txt" + - name: Verify actual installed origin and bytes + run: >- + uv run --python .artifact-venv --no-project + python scripts/ci/candidate.py verify-installed native-candidate --source-root src + - name: Run fresh-process native and cold-call regressions + run: >- + uv run --python .artifact-venv --no-project + python scripts/ci/native_stress.py --repetitions 10 diff --git a/scripts/ci/candidate.py b/scripts/ci/candidate.py index 5ef380b..343b445 100644 --- a/scripts/ci/candidate.py +++ b/scripts/ci/candidate.py @@ -2,7 +2,12 @@ import argparse import hashlib +import importlib +import importlib.metadata import json +import os +import subprocess +import sys import tarfile import zipfile from email.message import Message @@ -178,6 +183,53 @@ def verify( raise ValueError("candidate public metadata does not match the manifest") +def verify_installed(candidate_dir: Path, source_root: Path | None = None) -> None: + """Bind the imported package and installed bytes to the verified wheel.""" + verify(candidate_dir) + manifest = _load_manifest(candidate_dir) + if manifest["name"] != "ml4t-engineer": + raise ValueError("installed candidate must be ml4t-engineer") + distribution = importlib.metadata.distribution(manifest["name"]) + if distribution.version != manifest["version"]: + raise ValueError("installed candidate version does not match the manifest") + module = importlib.import_module("ml4t.engineer") + if module.__file__ is None: + raise ValueError("candidate import origin has no package file") + origin = Path(module.__file__).resolve() + prefix = Path(sys.prefix).resolve() + installed = Path(distribution.locate_file("ml4t/engineer/__init__.py")).resolve() + if origin != installed or not origin.is_relative_to(prefix): + raise ValueError(f"candidate import origin is not the installed wheel: {origin}") + wheel, _ = _distribution_files(candidate_dir) + with zipfile.ZipFile(wheel) as archive: + members = [ + item + for item in archive.infolist() + if item.filename.startswith("ml4t/engineer/") and not item.is_dir() + ] + if not any(item.filename == "ml4t/engineer/__init__.py" for item in members): + raise ValueError("candidate wheel does not contain ml4t.engineer") + for item in members: + path = Path(distribution.locate_file(item.filename)).resolve() + if not path.is_relative_to(prefix) or path.read_bytes() != archive.read(item): + raise ValueError(f"installed candidate bytes differ from wheel: {item.filename}") + print( + json.dumps({"version": distribution.version, "origin": str(origin), "files": len(members)}) + ) + if source_root is not None: + result = subprocess.run( + [sys.executable, str(Path(__file__).resolve()), "verify-installed", str(candidate_dir)], + env={**os.environ, "PYTHONPATH": str(source_root.resolve())}, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + if result.returncode == 0 or "candidate import origin" not in result.stderr: + raise ValueError("source-tree contamination was not rejected by the installed check") + print("Source-tree import contamination rejected") + + def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest="command", required=True) @@ -193,6 +245,10 @@ def _parser() -> argparse.ArgumentParser: verify_parser.add_argument("--expected-tree") verify_parser.add_argument("--expected-tag") + installed_parser = subparsers.add_parser("verify-installed") + installed_parser.add_argument("candidate_dir", type=Path) + installed_parser.add_argument("--source-root", type=Path) + return parser @@ -200,13 +256,15 @@ def main() -> None: args = _parser().parse_args() if args.command == "create": create(args.candidate_dir, args.commit_sha, args.git_tree) - else: + elif args.command == "verify": verify( args.candidate_dir, expected_commit=args.expected_commit, expected_tree=args.expected_tree, expected_tag=args.expected_tag, ) + else: + verify_installed(args.candidate_dir, args.source_root) if __name__ == "__main__": diff --git a/scripts/ci/native_stress.py b/scripts/ci/native_stress.py new file mode 100644 index 0000000..2dfa380 --- /dev/null +++ b/scripts/ci/native_stress.py @@ -0,0 +1,54 @@ +"""Fresh-process regressions for native bounds, call ordering and cold compilation.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +import yaml + +MACD = [ + "tests/features/momentum/test_momentum_numba_coverage.py::TestMACDNumbaDirectCoverage", + "tests/features/momentum/test_macd.py", +] +FEATURE_ORDER = [ + "tests/test_all_features_integration.py::test_all_features_compute_individually", + "tests/test_all_features_integration.py::test_all_features_compute_together", + "tests/test_all_features_integration.py::test_all_features_isolate_panel_assets", +] + + +def _run(nodes: list[str]) -> None: + subprocess.run( + [sys.executable, "-m", "pytest", *nodes, "-q", "--tb=short", "--no-cov"], + check=True, + timeout=600, + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--matrix", action="store_true") + parser.add_argument("--repetitions", type=int, default=10) + args = parser.parse_args() + if args.matrix: + workflow = yaml.load(Path(".github/workflows/ci.yml").read_text(), Loader=yaml.BaseLoader) + print("matrix=" + json.dumps(workflow["jobs"]["test"]["strategy"]["matrix"])) + return + if args.repetitions < 1: + parser.error("repetitions must be positive") + for iteration in range(args.repetitions): + print(f"Fresh MACD process {iteration + 1}/{args.repetitions}", flush=True) + _run(MACD if iteration % 2 == 0 else list(reversed(MACD))) + for nodes in (FEATURE_ORDER, list(reversed(FEATURE_ORDER))): + print("Fresh public feature-order process", flush=True) + _run(nodes) + print("Cold-call regressions, each with a fresh interpreter and empty Numba cache", flush=True) + _run(["tests/features/test_rolling_map_first_call.py"]) + + +if __name__ == "__main__": + main() diff --git a/tests/features/test_rolling_map_first_call.py b/tests/features/test_rolling_map_first_call.py index 990b92e..5f6469e 100644 --- a/tests/features/test_rolling_map_first_call.py +++ b/tests/features/test_rolling_map_first_call.py @@ -16,11 +16,13 @@ from __future__ import annotations +import json import os import subprocess import sys import textwrap +import numpy as np import pytest # feature -> the expression to build, as source, against columns `close` and `returns` @@ -41,7 +43,7 @@ PROBE = textwrap.dedent( """ - import logging, warnings + import json, logging, warnings warnings.filterwarnings("ignore") logging.disable(logging.WARNING) @@ -56,17 +58,16 @@ def evaluate(): values = frame.select(({expr}).alias("v")).to_series().to_numpy().astype(float) - finite = values[np.isfinite(values)] - return len(np.unique(finite)), finite.size + return values.tolist() - first, n = evaluate() - second, _ = evaluate() - print(f"{{first}} {{second}} {{n}}") + first = evaluate() + second = evaluate() + print(json.dumps([first, second])) """ ) -def _run(expr: str, tmp_path) -> tuple[int, int, int]: +def _run(expr: str, tmp_path) -> tuple[np.ndarray, np.ndarray]: """Evaluate the feature twice in a fresh interpreter with an empty Numba cache.""" env = dict(os.environ, NUMBA_CACHE_DIR=str(tmp_path), PYTHONWARNINGS="ignore") completed = subprocess.run( @@ -79,18 +80,24 @@ def _run(expr: str, tmp_path) -> tuple[int, int, int]: ) if completed.returncode != 0: pytest.fail(f"probe failed:\n{completed.stderr[-2000:]}") - first, second, n = (int(part) for part in completed.stdout.strip().split()[-3:]) - return first, second, n + first, second = json.loads(completed.stdout.strip().splitlines()[-1]) + return np.asarray(first), np.asarray(second) @pytest.mark.parametrize("name", sorted(FEATURES)) def test_first_evaluation_matches_the_second(name: str, tmp_path) -> None: - first, second, n = _run(FEATURES[name], tmp_path) + first, second = _run(FEATURES[name], tmp_path) + finite = first[np.isfinite(first)] + n = finite.size assert n > 100, f"{name} produced almost nothing to compare" - assert first == second, ( - f"{name} returns {first} distinct values on its first evaluation in a process " - f"and {second} on its second, over {n} rows: the rolling window did not advance " - f"while the kernel compiled" + np.testing.assert_allclose( + first, + second, + rtol=1e-12, + atol=1e-12, + equal_nan=True, + err_msg=f"{name} changes numerical output between its cold and second evaluation", ) - assert first > 2, f"{name} came out near-constant ({first} distinct values over {n} rows)" + distinct = len(np.unique(finite)) + assert distinct > 2, f"{name} came out near-constant ({distinct} distinct values over {n} rows)" diff --git a/tests/test_ci_invocation.py b/tests/test_ci_invocation.py index 86ec4da..9a3cf74 100644 --- a/tests/test_ci_invocation.py +++ b/tests/test_ci_invocation.py @@ -14,7 +14,9 @@ ROOT = Path(__file__).parents[1] -def _run_suite(tmp_path: Path, test: str, *, hook: str = "", options: str = ""): +def _run_suite( + tmp_path: Path, test: str, *, hook: str = "", options: str = "", native: bool = False +): workflow = yaml.load((ROOT / ".github/workflows/ci.yml").read_text(), Loader=yaml.BaseLoader) command = next( step["run"] @@ -25,7 +27,7 @@ def _run_suite(tmp_path: Path, test: str, *, hook: str = "", options: str = ""): command = command.replace("--python .artifact-venv", f"--python {shlex.quote(sys.executable)}") (tmp_path / "tests").mkdir() (tmp_path / "tests/test_case.py").write_text(test) - (tmp_path / "pytest.ini").write_text(f"[pytest]\naddopts = {options}\n") + (tmp_path / "pytest.ini").write_text(f"[pytest]\naddopts = -s {options}\n") (tmp_path / "conftest.py").write_text( "import os\nfrom pathlib import Path\n" "def pytest_sessionstart(session):\n" @@ -33,8 +35,33 @@ def _run_suite(tmp_path: Path, test: str, *, hook: str = "", options: str = ""): " stream.write('started\\n')\n" " if os.name == 'posix':\n" " import resource\n" - " resource.setrlimit(resource.RLIMIT_CORE, (0, 0))\n" + hook + " resource.setrlimit(resource.RLIMIT_CORE, (0, 0))\n" + " else:\n" + " import ctypes\n" + " ctypes.windll.kernel32.SetErrorMode(3)\n" + " ctypes.CDLL('ucrtbase')._set_abort_behavior(0, 3)\n" + hook ) + if native: + from scripts.ci.native_stress import FEATURE_ORDER, MACD + + direct = tmp_path / MACD[0].split("::")[0] + direct.parent.mkdir(parents=True) + body = test.removeprefix("import time\nimport pytest\n") + if body.startswith("VALUE ="): + body = "pass\n" + direct.write_text( + "import time\nimport pytest\nclass TestMACDNumbaDirectCoverage:\n" + + "".join(" " + line + "\n" for line in body.replace("():", "(self):").splitlines()) + ) + (tmp_path / MACD[1]).write_text(test) + (tmp_path / FEATURE_ORDER[0].split("::")[0]).write_text( + "\n".join(f"def {node.split('::')[1]}(): pass" for node in FEATURE_ORDER) + ) + (tmp_path / "tests/features/test_rolling_map_first_call.py").write_text(test) + command = ( + f"{shlex.quote(sys.executable)} " + f"{shlex.quote(str(ROOT / 'scripts/ci/native_stress.py'))} --repetitions 2" + ) return subprocess.run( ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", command], cwd=tmp_path, @@ -57,6 +84,13 @@ def test_routine_wheel_cell_runs_the_full_suite_once(tmp_path: Path) -> None: assert (tmp_path / "sessions").read_text().splitlines() == ["started"] +def test_native_stress_runs_fresh_ordered_processes_and_cold_call_control(tmp_path: Path) -> None: + result = _run_suite(tmp_path, "def test_pass():\n assert 2 + 2 == 4\n", native=True) + assert result.returncode == 0, result.stdout + result.stderr + assert (tmp_path / "sessions").read_text().splitlines() == ["started"] * 5 + + +@pytest.mark.parametrize("native", [False, True], ids=["full-suite", "native-stress"]) @pytest.mark.parametrize( ("test", "hook", "options", "message"), [ @@ -70,7 +104,13 @@ def test_routine_wheel_cell_runs_the_full_suite_once(tmp_path: Path) -> None: "", "real-internal-error", ), - ("def test_crashes():\n import os\n os.abort()\n", "", "", ""), + ( + "def test_crashes():\n import os\n" + " print('native-abort', flush=True)\n os.abort()\n", + "", + "", + "native-abort", + ), ( "import time\nimport pytest\n@pytest.mark.timeout(0.2)\n" "def test_times_out():\n time.sleep(30)\n", @@ -100,8 +140,8 @@ def test_routine_wheel_cell_runs_the_full_suite_once(tmp_path: Path) -> None: ], ) def test_configured_wheel_command_propagates_real_failure( - tmp_path: Path, test: str, hook: str, options: str, message: str + tmp_path: Path, test: str, hook: str, options: str, message: str, native: bool ) -> None: - result = _run_suite(tmp_path, test, hook=hook, options=options) + result = _run_suite(tmp_path, test, hook=hook, options=options, native=native) assert result.returncode != 0, result.stdout + result.stderr assert message in result.stdout + result.stderr From 6267e95ca8383bb78a9d2a41013f946a24c5167c Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 15:36:25 -0400 Subject: [PATCH 11/28] ci: bound native build and use explicit fail-closed shells (#99) --- .github/workflows/native-stress.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/native-stress.yml b/.github/workflows/native-stress.yml index 8896765..28369a8 100644 --- a/.github/workflows/native-stress.yml +++ b/.github/workflows/native-stress.yml @@ -39,6 +39,7 @@ concurrency: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 20 outputs: matrix: ${{ steps.matrix.outputs.matrix }} steps: @@ -105,10 +106,12 @@ jobs: uv pip install --python .artifact-venv \ --requirements "${{ runner.temp }}/stress-requirements.txt" - name: Verify actual installed origin and bytes + shell: bash run: >- uv run --python .artifact-venv --no-project python scripts/ci/candidate.py verify-installed native-candidate --source-root src - name: Run fresh-process native and cold-call regressions + shell: bash run: >- uv run --python .artifact-venv --no-project python scripts/ci/native_stress.py --repetitions 10 From 3a26a9479d707440e1a5cf2e9cce177c29a7baba Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 15:43:41 -0400 Subject: [PATCH 12/28] test(ci): reject real installed version and package drift (#99) --- tests/test_release_candidate.py | 121 ++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/tests/test_release_candidate.py b/tests/test_release_candidate.py index f883817..617eddf 100644 --- a/tests/test_release_candidate.py +++ b/tests/test_release_candidate.py @@ -1,10 +1,13 @@ from __future__ import annotations import json +import os import subprocess import sys import tarfile +import tempfile import zipfile +from collections.abc import Iterator from io import BytesIO from pathlib import Path from urllib.error import HTTPError @@ -42,6 +45,124 @@ def test_candidate_manifest_binds_both_artifacts_to_source(candidate_dir: Path) assert all(len(record["sha256"]) == 64 for record in manifest["artifacts"]) +@pytest.fixture(scope="module") +def installed_candidate(candidate_dir: Path) -> Iterator[Path]: + root = os.environ.get("RUNNER_TEMP") or os.environ.get("TMPDIR") + if root is None: + cache = Path(__file__).parents[1] / ".pytest_cache" + cache.mkdir(exist_ok=True) + root = str(cache) + with tempfile.TemporaryDirectory(prefix="engineer-installed-check-", dir=root) as directory: + environment = Path(directory) / "environment" + subprocess.run( + ["uv", "venv", "--python", sys.executable, str(environment)], + check=True, + capture_output=True, + ) + wheel, _ = candidate._distribution_files(candidate_dir) + subprocess.run( + [ + "uv", + "pip", + "install", + "--python", + str(environment), + "--link-mode", + "copy", + str(wheel), + ], + check=True, + capture_output=True, + ) + yield environment / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + + +def _installed_check(python: Path, directory: Path, **environment: str): + return subprocess.run( + [str(python), str(Path(candidate.__file__).resolve()), "verify-installed", str(directory)], + env={**os.environ, **environment}, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + + +def test_installed_candidate_rejects_real_source_tree_shadowing( + candidate_dir: Path, installed_candidate: Path +) -> None: + valid = _installed_check(installed_candidate, candidate_dir) + assert valid.returncode == 0, valid.stderr + source = Path(__file__).parents[1] / "src" + legacy = subprocess.run( + [str(installed_candidate), "-c", "import ml4t.engineer; print(ml4t.engineer.__file__)"], + env={**os.environ, "PYTHONPATH": str(source)}, + capture_output=True, + text=True, + check=False, + ) + assert legacy.returncode == 0, legacy.stderr + assert Path(legacy.stdout.strip()).is_relative_to(source) + rejected = _installed_check(installed_candidate, candidate_dir, PYTHONPATH=str(source)) + assert rejected.returncode != 0 + assert "candidate import origin" in rejected.stderr + + +def test_installed_candidate_rejects_modified_installed_package_bytes( + candidate_dir: Path, installed_candidate: Path +) -> None: + location = subprocess.run( + [str(installed_candidate), "-c", "import ml4t.engineer; print(ml4t.engineer.__file__)"], + check=True, + capture_output=True, + text=True, + ) + path = Path(location.stdout.strip()).parent / "features/trend/sma.py" + original = path.read_bytes() + try: + path.write_bytes(original + b"\n# Unexpected installed-file modification.\n") + legacy = subprocess.run( + [str(installed_candidate), "-c", "import ml4t.engineer"], + capture_output=True, + text=True, + check=False, + ) + assert legacy.returncode == 0, legacy.stderr + rejected = _installed_check(installed_candidate, candidate_dir) + assert rejected.returncode != 0 + assert "installed candidate bytes differ" in rejected.stderr + finally: + path.write_bytes(original) + + +def test_installed_candidate_rejects_another_real_wheel_version( + candidate_dir: Path, installed_candidate: Path, tmp_path: Path +) -> None: + subprocess.run( + ["uv", "build", "--wheel", "--out-dir", str(tmp_path)], + env={**os.environ, "SETUPTOOLS_SCM_PRETEND_VERSION": "0.0.0"}, + check=True, + capture_output=True, + ) + original, _ = candidate._distribution_files(candidate_dir) + other = next(tmp_path.glob("*.whl")) + install = ["uv", "pip", "install", "--python", str(installed_candidate), "--link-mode", "copy"] + try: + subprocess.run([*install, str(other)], check=True, capture_output=True) + legacy = subprocess.run( + [str(installed_candidate), "-c", "import ml4t.engineer"], + capture_output=True, + text=True, + check=False, + ) + assert legacy.returncode == 0, legacy.stderr + rejected = _installed_check(installed_candidate, candidate_dir) + assert rejected.returncode != 0 + assert "installed candidate version does not match" in rejected.stderr + finally: + subprocess.run([*install, str(original)], check=True, capture_output=True) + + @pytest.mark.parametrize("source_dependency", [False, True]) def test_release_selection_rejects_real_source_dependent_artifacts_before_publication( tmp_path: Path, source_dependency: bool From b95fb29ca92cf80579f4ef890568c389d1e18811 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 15:46:51 -0400 Subject: [PATCH 13/28] build: qualify published Specs 0.2 temporal contract (#76) --- docs/user-guide/features.md | 8 +++----- pyproject.toml | 6 +----- tests/test_release_policy.py | 17 +++++------------ uv.lock | 10 +++++++--- 4 files changed, 16 insertions(+), 25 deletions(-) diff --git a/docs/user-guide/features.md b/docs/user-guide/features.md index d5c2160..7d7cd79 100644 --- a/docs/user-guide/features.md +++ b/docs/user-guide/features.md @@ -87,11 +87,9 @@ raise `ValueError` before feature execution. ### Calendar and bar grid The calendar-aware default targets the next minor version (0.2), separate from the 0.1.7 patch. -Development and qualification currently pin the merged Specs v2 implementation at -`701bde68744035050ede386efce498234c661c21`. The same immutable Git dependency is declared -in built package metadata; published Specs 0.1.5 does not provide this API. Before a PyPI -release, replace that source dependency with the verified published Specs version containing -the v2 contract and repeat installed-package qualification. +The shared temporal contract requires published `ml4t-specs>=0.2.0,<0.3.0`, also declared +in built package metadata. Specs 0.1.5 does not provide this API. Engineer's development +branch uses the published dependency; the temporal migration is not part of the 0.1.7 patch. Calendar selection uses the explicit `calendar` argument, then declared feed metadata, then NYSE. Explicit diff --git a/pyproject.toml b/pyproject.toml index 0dd9f84..93321da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,10 +8,6 @@ build-backend = "hatchling.build" [tool.hatch.version] source = "vcs" -[tool.hatch.metadata] -# Qualification pin until the shared time-contract v2 API is published on PyPI. -allow-direct-references = true - [tool.hatch.build.targets.wheel] packages = ["src/ml4t"] namespaces = true @@ -76,7 +72,7 @@ requires-python = ">=3.12,<3.15" # Core dependencies dependencies = [ - "ml4t-specs @ git+https://github.com/ml4t/specs.git@701bde68744035050ede386efce498234c661c21", + "ml4t-specs>=0.2.0,<0.3.0", # Data processing (Polars-first) "polars>=0.20.0", diff --git a/tests/test_release_policy.py b/tests/test_release_policy.py index 07db42a..87afb2b 100644 --- a/tests/test_release_policy.py +++ b/tests/test_release_policy.py @@ -242,7 +242,7 @@ def test_ci_audits_core_and_complete_locked_environments() -> None: assert "complete.txt" in audit -def test_core_dependency_uses_bounded_specs_or_immutable_source_for_qualification() -> None: +def test_core_dependency_requires_published_time_contract_v2() -> None: project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) lock = tomllib.loads((ROOT / "uv.lock").read_text(encoding="utf-8")) @@ -252,17 +252,10 @@ def test_core_dependency_uses_bounded_specs_or_immutable_source_for_qualificatio if Requirement(value).name == "ml4t-specs" ) specs = next(package for package in lock["package"] if package["name"] == "ml4t-specs") - if requirement.url is None: - assert str(requirement.specifier) == "<0.2.0,>=0.1.0" - assert specs["version"] in requirement.specifier - else: - assert re.fullmatch( - r"git\+https://github.com/ml4t/specs\.git@[0-9a-f]{40}", requirement.url - ) - commit = requirement.url.rsplit("@", maxsplit=1)[1] - assert specs["source"] == { - "git": f"https://github.com/ml4t/specs.git?rev={commit}#{commit}" - } + assert requirement.url is None + assert str(requirement.specifier) == "<0.3.0,>=0.2.0" + assert specs["version"] in requirement.specifier + assert specs["source"] == {"registry": "https://pypi.org/simple"} def test_package_supports_python_312_through_314() -> None: diff --git a/uv.lock b/uv.lock index af62738..c5c6d49 100644 --- a/uv.lock +++ b/uv.lock @@ -1651,7 +1651,7 @@ requires-dist = [ { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5.0" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'all'", specifier = ">=0.24.0" }, { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.24.0" }, - { name = "ml4t-specs", git = "https://github.com/ml4t/specs.git?rev=701bde68744035050ede386efce498234c661c21" }, + { name = "ml4t-specs", specifier = ">=0.2.0,<0.3.0" }, { name = "numba", specifier = ">=0.57.0" }, { name = "numpy", specifier = ">=1.24.0,<2.6" }, { name = "pandas", specifier = ">=2.0.0" }, @@ -1728,11 +1728,15 @@ test = [ [[package]] name = "ml4t-specs" -version = "0.1.5" -source = { git = "https://github.com/ml4t/specs.git?rev=701bde68744035050ede386efce498234c661c21#701bde68744035050ede386efce498234c661c21" } +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/2e/46/c734e5bcac1f214c83423243023b5d313bccabd25563da4e7d5e6b80f9f4/ml4t_specs-0.2.0.tar.gz", hash = "sha256:e5d5d614f71d14223341f992009d0d0bf14f30a8398c50872d2b3fb80fd33d82", size = 59749, upload-time = "2026-10-17T18:21:32.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/ff/bfc427bb71635f31cb240330aa2b1b486748999f05a5922ee5f58df721fe/ml4t_specs-0.2.0-py3-none-any.whl", hash = "sha256:cfd550d19b469f1b083d6ac3f83b235fe71aee19c28465f2ac2c8620064bae61", size = 36946, upload-time = "2026-10-17T18:21:30.964Z" }, +] [[package]] name = "more-itertools" From 72e856d586e8398f1fdf4076875a41b60c7c291b Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 16:00:34 -0400 Subject: [PATCH 14/28] fix(grid): validate daily session labels before feature returns (#76) --- docs/user-guide/features.md | 43 +++++- src/ml4t/engineer/core/bar_grid.py | 97 +++++++++---- tests/test_api_session_grid.py | 220 +++++++++++++++++++++++++++++ 3 files changed, 330 insertions(+), 30 deletions(-) create mode 100644 tests/test_api_session_grid.py diff --git a/docs/user-guide/features.md b/docs/user-guide/features.md index d5c2160..7be1fe6 100644 --- a/docs/user-guide/features.md +++ b/docs/user-guide/features.md @@ -98,12 +98,43 @@ uses the explicit `calendar` argument, then declared feed metadata, then NYSE. E `calendar=None` disables schedule interpretation. A named `"24/7"` calendar instead defines expected bars and detects outages, including on weekends. -Calendar-aware execution requires a shared `FeedSpec(time_contract_version=2)` with an -explicit fixed intraday frequency and bar-grid policy. The initial implementation supports -time bars anchored at each tradable interval's open. It uses real holidays, early closes and -breaks from the selected schedule. Daily/session-label grids and session or elapsed-duration -windows are not supported by this validator; they fail rather than being treated as row counts. -Naive timestamps need a declared timezone. Aware timestamps already declare their timezone. +Calendar-aware execution requires a shared `FeedSpec(time_contract_version=2)` with declared +frequency and timestamp meaning. Fixed intraday time bars require an explicit bar-grid policy, +anchored at each tradable interval's open. The selected schedule supplies holidays, early closes +and breaks. Naive instants need a declared timezone; aware instants declare their timezone. + +Daily bars instead declare `timestamp_semantics="session_label"`, `data_frequency="1d"` and +`bar_type="time"`. Do not supply intraday anchor, label-side or partial-bar fields for session +labels. Date values and datetime representations identify the displayed session date, without +timezone conversion. Validation checks every scheduled session between each asset's first and +last label, including completely missing sessions. A named `24/7` schedule includes weekends; +an NYSE schedule does not. No prices are filled or rows inserted. A daily frequency alone does +not turn `event_time` instants into session labels. Session-count and elapsed-duration feature +windows remain unsupported; they fail rather than being treated as expected-bar counts. + + +```python +from datetime import date + +import polars as pl +from ml4t.engineer import compute_features +from ml4t.specs import FeedSpec + +close = [100.0, 103.0, 106.0, 109.0] +daily_prices = pl.DataFrame({ + "timestamp": [date(2025, 7, day) for day in (3, 7, 8, 9)], + "open": close, "high": close, "low": close, "close": close, "volume": 1.0, +}) +daily_feed = FeedSpec( + time_contract_version=2, calendar="NYSE", data_frequency="1d", + timestamp_semantics="session_label", bar_type="time", +) +checked = compute_features(daily_prices, [], feed_spec=daily_feed) +returns = checked.with_columns(pl.col("close").log().diff().alias("returns")) +features = compute_features(returns, [{"name": "sma", "params": {"period": 2}}], feed_spec=daily_feed) +assert features["sma"].to_list() == [None, 101.5, 104.5, 107.5] +assert features["timestamp"].equals(daily_prices["timestamp"]) +``` Validate prices before deriving returns. An empty feature list performs the same grid validation and chronological sorting without adding feature columns: diff --git a/src/ml4t/engineer/core/bar_grid.py b/src/ml4t/engineer/core/bar_grid.py index 5155e65..ebdfc5a 100644 --- a/src/ml4t/engineer/core/bar_grid.py +++ b/src/ml4t/engineer/core/bar_grid.py @@ -21,15 +21,15 @@ def validate_feature_grid( timestamp_col: str | None, group_cols: list[str], ) -> None: - """Bind shared v2 declarations to the fixed-intraday schedule checker.""" + """Validate shared v2 daily session labels or fixed intraday bar timestamps.""" + daily_labels = feed_spec.timestamp_semantics == TimestampSemantics.SESSION_LABEL required = ( "data_frequency", "timestamp_semantics", "bar_type", - "bar_grid_anchor", - "bar_label", - "partial_bar_policy", ) + if not daily_labels: + required += ("bar_grid_anchor", "bar_label", "partial_bar_policy") missing = [name for name in required if getattr(feed_spec, name) is None] if feed_spec.time_contract_version != 2 or missing or timestamp_col is None: raise ValueError( @@ -41,16 +41,10 @@ def validate_feature_grid( if feed_spec.timestamp_semantics not in { TimestampSemantics.BAR_CLOSE, TimestampSemantics.EVENT_TIME, + TimestampSemantics.SESSION_LABEL, }: - raise ValueError("Fixed intraday bar grids require event_time or bar_close timestamps") + raise ValueError("Bar grids require event_time, bar_close or session_label timestamps") schema = data.collect_schema() if isinstance(data, pl.LazyFrame) else data.schema - dtype = schema[timestamp_col] - timezone = feed_spec.timezone - if timezone is None and isinstance(dtype, pl.Datetime): - timezone = dtype.time_zone - if timezone is None: - raise ValueError("A declared bar grid requires timezone metadata for naive timestamps") - frequency = feed_spec.data_frequency if isinstance(frequency, str): import pandas as pd @@ -59,7 +53,7 @@ def validate_feature_grid( with warnings.catch_warnings(): warnings.simplefilter("error", FutureWarning) warnings.simplefilter("error", DeprecationWarning) - parsed = pd.Timedelta(frequency) + parsed = pd.Timedelta("1D" if frequency == "1d" else frequency) except (ValueError, TypeError, FutureWarning, DeprecationWarning) as exc: raise ValueError("declared grid data_frequency must be a fixed duration") from exc if not isinstance(parsed, pd.Timedelta) or parsed.value % 1000: @@ -67,17 +61,32 @@ def validate_feature_grid( frequency = parsed.to_pytimedelta() if not isinstance(frequency, timedelta): raise ValueError("declared grid data_frequency must be a duration string or timedelta") - validate_bar_grid( - data, - calendar=calendar, - frequency=frequency, - timezone=timezone, - timestamp_col=timestamp_col, - group_cols=group_cols, - anchor=cast(Literal["session_open"], feed_spec.bar_grid_anchor), - label_side=cast(Literal["left", "right"], feed_spec.bar_label), - include_partial=feed_spec.partial_bar_policy == "include", - ) + if daily_labels: + _validate_session_grid( + data, + calendar=calendar, + frequency=frequency, + timestamp_col=timestamp_col, + group_cols=group_cols, + ) + else: + dtype = schema[timestamp_col] + timezone = feed_spec.timezone + if timezone is None and isinstance(dtype, pl.Datetime): + timezone = dtype.time_zone + if timezone is None: + raise ValueError("A declared bar grid requires timezone metadata for naive timestamps") + validate_bar_grid( + data, + calendar=calendar, + frequency=frequency, + timezone=timezone, + timestamp_col=timestamp_col, + group_cols=group_cols, + anchor=cast(Literal["session_open"], feed_spec.bar_grid_anchor), + label_side=cast(Literal["left", "right"], feed_spec.bar_label), + include_partial=feed_spec.partial_bar_policy == "include", + ) if "returns" in schema: ordered = data.sort([*group_cols, timestamp_col]) missing_price = ~pl.col("close").cast(pl.Float64).is_finite().fill_null(False) @@ -96,6 +105,46 @@ def validate_feature_grid( ) +def _validate_session_grid( + data: pl.DataFrame | pl.LazyFrame, + *, + calendar: str, + frequency: timedelta, + timestamp_col: str, + group_cols: list[str], +) -> None: + if frequency != timedelta(days=1): + raise ValueError("Daily session labels require declared data_frequency='1d'") + from ml4t.engineer.labeling.calendar import PandasMarketCalendar + + adapter = PandasMarketCalendar(calendar) + labels = data.select([*group_cols, pl.col(timestamp_col).dt.date()]) + frame = labels.collect() if isinstance(labels, pl.LazyFrame) else labels + if frame[timestamp_col].null_count(): + raise ValueError("declared daily grid contains null session labels") + groups = frame.partition_by(group_cols, as_dict=True) if group_cols else {(): frame} + for group, rows in groups.items(): + actual = sorted(rows[timestamp_col].to_list()) + if not actual: + continue + observed = set(actual) + if len(actual) != len(observed): + raise ValueError(f"duplicate session label in group {group!r}") + schedule = adapter._calendar.schedule(start_date=actual[0], end_date=actual[-1]) + expected = {label.date() for label in schedule.index} + missing = sorted(expected - observed) + if missing: + raise ValueError( + f"missing scheduled session in group {group!r} at {missing[0].isoformat()}; " + "validate daily prices before deriving returns, or explicitly select calendar=None" + ) + off_schedule = sorted(observed - expected) + if off_schedule: + raise ValueError( + f"off-schedule session label in group {group!r} at {off_schedule[0].isoformat()}" + ) + + def validate_bar_grid( data: pl.DataFrame | pl.LazyFrame, *, diff --git a/tests/test_api_session_grid.py b/tests/test_api_session_grid.py new file mode 100644 index 0000000..dc31abe --- /dev/null +++ b/tests/test_api_session_grid.py @@ -0,0 +1,220 @@ +from dataclasses import replace +from datetime import UTC, date, datetime, time, timedelta +from math import log +from statistics import stdev +from zoneinfo import ZoneInfo + +import polars as pl +import pytest +from ml4t.specs import UNSET_CALENDAR, FeedSpec, read_spec_payload, write_spec_payload + +from ml4t.engineer import compute_features +from ml4t.engineer.core.exceptions import DataSchemaError + +REQUEST = [ + {"name": "sma", "params": {"period": 2}}, + {"name": "realized_volatility", "params": {"period": 2, "annualize": False}}, +] + + +def prices(days, symbol="TEST"): + close = [100.0 + 3 * index for index in range(len(days))] + return pl.DataFrame( + { + "timestamp": days, + "symbol": symbol, + "open": close, + "high": close, + "low": close, + "close": close, + "volume": 1.0, + } + ) + + +def contract(calendar=UNSET_CALENDAR): + return FeedSpec( + time_contract_version=2, + calendar=calendar, + entity_col="symbol", + timestamp_semantics="session_label", + data_frequency="1d", + bar_type="time", + ) + + +def materialize(data): + return data.collect() if isinstance(data, pl.LazyFrame) else data + + +def derive_returns(data): + return data.with_columns(pl.col("close").log().diff().over("symbol").alias("returns")) + + +@pytest.mark.parametrize("lazy", [False, True]) +@pytest.mark.parametrize("representation", ["date", "naive", "UTC", "Asia/Tokyo"]) +def test_daily_price_returns_features_preserve_session_identifiers_across_closures( + lazy, representation +): + days = [date(2025, 7, day) for day in (3, 7, 8, 9)] + if representation != "date": + zone = None if representation == "naive" else ZoneInfo(representation) + days = [datetime.combine(day, time(0, 15), tzinfo=zone) for day in days] + frame = prices(days) + original = frame.clone() + data = frame.lazy() if lazy else frame + checked = compute_features(data.reverse(), [], feed_spec=contract()) + assert isinstance(checked, type(data)) + assert materialize(checked).equals(original) + result = materialize(compute_features(derive_returns(checked), REQUEST, feed_spec=contract())) + assert result.select(frame.columns).equals(frame) + assert result["sma"].to_list() == [None, 101.5, 104.5, 107.5] + assert result["realized_volatility"][2] == pytest.approx( + stdev([log(103 / 100), log(106 / 103)]) + ) + assert frame.equals(original) + + +@pytest.mark.parametrize("lazy", [False, True]) +def test_removed_daily_price_cannot_be_interpreted_as_a_one_bar_return(lazy): + complete = prices([date(2025, 7, day) for day in range(14, 19)]) + incomplete = complete.filter(pl.col("timestamp") != date(2025, 7, 16)) + original = incomplete.clone() + for frame, requests in [(incomplete, []), (derive_returns(incomplete), REQUEST)]: + data = frame.lazy() if lazy else frame + with pytest.raises(ValueError, match="missing scheduled session.*2025-07-16"): + compute_features(data, requests, feed_spec=contract("NYSE")) + assert incomplete.equals(original) + + +def test_complete_asset_does_not_hide_another_assets_missing_daily_price(): + complete = prices([date(2025, 7, day) for day in range(14, 19)], "COMPLETE") + missing = complete.with_columns(pl.lit("MISSING").alias("symbol")).filter( + pl.col("timestamp") != date(2025, 7, 16) + ) + with pytest.raises(ValueError, match="missing scheduled session.*MISSING.*2025-07-16"): + compute_features( + derive_returns(pl.concat([complete, missing])), REQUEST, feed_spec=contract() + ) + + +@pytest.mark.parametrize("frequency", ["1d", "1D", "24h", timedelta(days=1)]) +def test_complete_continuous_daily_grid_includes_weekends_without_equity_annualization(frequency): + frame = derive_returns(prices([date(2025, 7, day) for day in range(10, 15)])) + result = compute_features( + frame, REQUEST, feed_spec=replace(contract("24/7"), data_frequency=frequency) + ) + assert result.height == 5 + assert result["realized_volatility"][2] == pytest.approx( + stdev([log(103 / 100), log(106 / 103)]) + ) + with pytest.raises(ValueError, match="off-schedule"): + compute_features(frame, [], feed_spec=contract("NYSE")) + + +def test_reindexing_derived_daily_returns_does_not_hide_a_cross_session_return(): + complete = prices([date(2025, 7, day) for day in range(14, 19)]) + missing = complete.filter(pl.col("timestamp") != date(2025, 7, 16)) + unsafe = ( + complete.select("timestamp") + .join(derive_returns(missing), on="timestamp", how="left") + .with_columns(pl.col("symbol").fill_null("TEST")) + ) + original = unsafe.clone() + with pytest.raises(ValueError, match="Return adjacent to a missing price"): + compute_features(unsafe, REQUEST, feed_spec=contract()) + assert unsafe.equals(original) + safe_prices = ( + complete.select("timestamp") + .join(missing, on="timestamp", how="left") + .with_columns(pl.col("symbol").fill_null("TEST")) + ) + safe = compute_features(derive_returns(safe_prices), REQUEST, feed_spec=contract()) + assert safe.height == 5 + assert safe["returns"][3] is None + assert safe["realized_volatility"][3] is None + + +@pytest.mark.parametrize("count", [0, 1]) +@pytest.mark.parametrize("lazy", [False, True]) +def test_empty_and_single_daily_grids_preserve_declared_schema_and_shape(count, lazy): + frame = prices([date(2025, 7, 3)]).head(count) + data = frame.lazy() if lazy else frame + assert materialize(compute_features(data, [], feed_spec=contract())).equals(frame) + + +@pytest.mark.parametrize("suffix", [".json", ".yaml"]) +def test_serialized_daily_calendar_selection_does_not_turn_continuous_weekends_into_closures( + tmp_path, suffix +): + frame = prices([date(2025, 7, 11), date(2025, 7, 14)]) + path = tmp_path / f"feed{suffix}" + write_spec_payload(contract("24/7").to_dict(), path) + metadata = read_spec_payload(path) + with pytest.raises(ValueError, match="missing scheduled session.*2025-07-12"): + compute_features(frame, [], feed_spec=metadata) + assert compute_features(frame, [], feed_spec=metadata, calendar="NYSE").equals(frame) + assert compute_features(frame, [], feed_spec=metadata, calendar=None).equals(frame) + write_spec_payload(contract().to_dict(), path) + payload = read_spec_payload(path) + assert "calendar" not in payload + assert compute_features(frame, [], feed_spec=payload).equals(frame) + + +@pytest.mark.parametrize("kind", ["duplicate", "closed", "null"]) +def test_invalid_daily_session_labels_fail_without_changing_prices(kind): + days = [date(2025, 7, day) for day in (3, 7, 8)] + if kind == "null": + days[1] = None + else: + days.insert(1, days[0] if kind == "duplicate" else date(2025, 7, 4)) + frame = prices(days) + original = frame.clone() + with pytest.raises((ValueError, DataSchemaError), match="duplicate|off-schedule|null"): + compute_features(frame, [], feed_spec=contract()) + assert frame.equals(original) + + +def test_distinct_instants_with_the_same_session_identifier_are_duplicate_daily_bars(): + frame = prices([datetime(2025, 7, 3, hour, tzinfo=UTC) for hour in (10, 11)]) + with pytest.raises(ValueError, match="duplicate session label"): + compute_features(frame, [], feed_spec=contract()) + + +def test_overnight_exchange_uses_session_labels_not_the_open_instant_date(): + frame = prices([date(2025, 7, day) for day in (7, 8)]) + assert compute_features(frame, [], feed_spec=contract("CME_Equity")).equals(frame) + sunday = prices([date(2025, 7, 6)]) + with pytest.raises(ValueError, match="off-schedule session label"): + compute_features(sunday, [], feed_spec=contract("CME_Equity")) + + +@pytest.mark.parametrize("frequency", [None, "1h", "2d", timedelta(0)]) +def test_daily_labels_require_a_declared_single_session_frequency(frequency): + frame = prices([date(2025, 7, 3)]) + with pytest.raises(ValueError, match="frequency|declared bar grid"): + compute_features(frame, [], feed_spec=replace(contract(), data_frequency=frequency)) + + +def test_daily_frequency_does_not_reclassify_event_instants_as_session_labels(): + frame = prices([datetime(2025, 7, 3, tzinfo=UTC)]) + instant = replace( + contract(), + timestamp_semantics="event_time", + bar_grid_anchor="session_open", + bar_label="left", + partial_bar_policy="include", + timezone="UTC", + ) + with pytest.raises(ValueError, match="frequency.*below one day"): + compute_features(frame, [], feed_spec=instant) + + +def test_disabled_calendar_keeps_daily_observation_order_without_loading_a_schedule(monkeypatch): + import sys + + monkeypatch.setitem(sys.modules, "pandas_market_calendars", None) + frame = derive_returns(prices([date(2025, 7, day) for day in (8, 4, 20, 7)])) + result = compute_features(frame, REQUEST, feed_spec=contract(None)) + assert result.select(frame.columns).equals(frame) + assert result["sma"].to_list() == [None, 101.5, 104.5, 107.5] From fde92ea963eef11cea490f08c8bd1a9ac2014ba9 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 16:11:03 -0400 Subject: [PATCH 15/28] fix(ci): execute Git Bash rather than Windows WSL launcher (#99) --- tests/test_ci_invocation.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/test_ci_invocation.py b/tests/test_ci_invocation.py index 9a3cf74..49916dc 100644 --- a/tests/test_ci_invocation.py +++ b/tests/test_ci_invocation.py @@ -4,6 +4,7 @@ import os import shlex +import shutil import subprocess import sys from pathlib import Path @@ -14,6 +15,21 @@ ROOT = Path(__file__).parents[1] +def _bash() -> str: + if os.name == "nt": + git = shutil.which("git") + if git is not None: + for parent in Path(git).resolve().parents: + for relative in ("bin/bash.exe", "usr/bin/bash.exe"): + candidate = parent / relative + if candidate.is_file(): + return str(candidate) + raise AssertionError("The configured Bash workflow requires Git Bash on Windows, not WSL") + bash = shutil.which("bash") + assert bash is not None, "The configured Bash workflow requires Bash" + return bash + + def _run_suite( tmp_path: Path, test: str, *, hook: str = "", options: str = "", native: bool = False ): @@ -24,7 +40,8 @@ def _run_suite( if "python -m pytest tests/" in step.get("run", "") ) # Bind the runner's interpreter input, without replacing its shell/exit logic. - command = command.replace("--python .artifact-venv", f"--python {shlex.quote(sys.executable)}") + python = shlex.quote(Path(sys.executable).as_posix()) + command = command.replace("--python .artifact-venv", f"--python {python}") (tmp_path / "tests").mkdir() (tmp_path / "tests/test_case.py").write_text(test) (tmp_path / "pytest.ini").write_text(f"[pytest]\naddopts = -s {options}\n") @@ -59,11 +76,11 @@ def _run_suite( ) (tmp_path / "tests/features/test_rolling_map_first_call.py").write_text(test) command = ( - f"{shlex.quote(sys.executable)} " - f"{shlex.quote(str(ROOT / 'scripts/ci/native_stress.py'))} --repetitions 2" + f"{python} " + f"{shlex.quote((ROOT / 'scripts/ci/native_stress.py').as_posix())} --repetitions 2" ) return subprocess.run( - ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", command], + [_bash(), "--noprofile", "--norc", "-eo", "pipefail", "-c", command], cwd=tmp_path, env={ **os.environ, From 05521bf49865b59b76b82d0ad8ef11b3a2f37df7 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 18:17:46 -0400 Subject: [PATCH 16/28] feat(labeling): apply scheduled-session horizons through existing barrier engine (#94) --- docs/user-guide/labeling.md | 28 +++- src/ml4t/engineer/core/bar_grid.py | 46 ++++++ src/ml4t/engineer/labeling/AGENTS.md | 7 +- src/ml4t/engineer/labeling/calendar.py | 92 +++++++++-- tests/test_labeling_session_windows.py | 218 +++++++++++++++++++++++++ tests/test_labeling_time_contract.py | 2 +- 6 files changed, 375 insertions(+), 18 deletions(-) create mode 100644 tests/test_labeling_session_windows.py diff --git a/docs/user-guide/labeling.md b/docs/user-guide/labeling.md index 3e550f5..5ca6dfb 100644 --- a/docs/user-guide/labeling.md +++ b/docs/user-guide/labeling.md @@ -439,7 +439,7 @@ former pause because trading is continuous until 4:00 PM. See the [CME exchange notice](https://www.cmegroup.com/notices/electronic-trading/2021/06/20210621.html) for the affected equity-index contracts. -### Observation rows, expected bars and elapsed durations +### Observation rows, expected bars, sessions and elapsed durations Use `calendar=None` for observation-row labeling, including timestamp-indexed data: @@ -465,12 +465,11 @@ omitted selectors default count windows to `"expected_bar"`; an undeclared or in grid fails before labeling. Supply the complete shared grid declarations described in the [feature guide](features.md#calendar-and-bar-grid) to reject missing scheduled bars before labeling. Named `"24/7"` still has daily schedule boundaries -and is not equivalent to row mode. `"session"` count windows are not implemented and fail -explicitly. Duration holding periods select `"elapsed"` and remain truncated at session +and is not equivalent to row mode. Duration holding periods select `"elapsed"` and remain truncated at session boundaries, rather than counting sessions or observed dates. Pass `feed_spec=FeedSpec(...)` directly or through `LabelingConfig.feed_spec`. Shared -`timestamp_semantics` must declare an intraday instant; `session_label` is rejected. +`timestamp_semantics` must distinguish intraday instants from `session_label` identifiers. Naive feed timestamps require an explicit timezone, and ambiguous/nonexistent local times fail. Right-labeled/bar-close observations at a break boundary end the preceding tradable interval. Input timestamp representations are retained in the output. @@ -486,6 +485,27 @@ definitions and chronological sorting; they do not acquire calendar interpretati timestamps. Use this wrapper when selecting the shared calendar/row contract. Standalone kernels reject configs declaring calendar/feed/window semantics instead of silently ignoring those declarations. + +For `window_unit="session"`, an integer `max_holding_period=N` expires at the final +bar of the Nth scheduled session after the entry session. A positive integral count +column can select N per event. July 3, 2025 on NYSE therefore reaches July 7 for N=1, +not the July 4 holiday. Intraday entries reach the following session's last bar, +including an early close; lunch and overnight breaks do not truncate this horizon. +Profit, stop and trailing barriers still use the existing triple-barrier calculation. +The full declared grid is checked before any labels are computed, including whole-session +outages. No prices are filled or rows inserted. + +Daily input uses `FeedSpec(time_contract_version=2, calendar="NYSE", +timestamp_semantics="session_label", data_frequency="1d", bar_type="time")`. +Do not supply intraday anchor/label/partial-bar fields. Date-valued and midnight datetime +identifiers keep their displayed session date; they are not converted to UTC instants. +Daily observation and expected-bar horizons scan across session rows rather than +truncating every label to its entry row. Elapsed horizons reject these identifiers. + +As with existing count horizons, a deadline beyond the available dataset is censored +at the last available bar. `label_bars` reports the actual observed holding length and +`label_time` the actual exit, not an inferred future observation. These changes target +the next 0.2 development release, not the historical 0.1.7 patch. Their legacy `Date` inputs retain date-valued label times and calendar-day differences in `label_duration`; those dates do not become authoritative session identifiers or intraday instants. Shared-feed session labels remain explicitly rejected by this wrapper. diff --git a/src/ml4t/engineer/core/bar_grid.py b/src/ml4t/engineer/core/bar_grid.py index 5e3d1b1..f8ac87d 100644 --- a/src/ml4t/engineer/core/bar_grid.py +++ b/src/ml4t/engineer/core/bar_grid.py @@ -6,6 +6,8 @@ from typing import TYPE_CHECKING, Literal, cast from zoneinfo import ZoneInfo +import numpy as np +import numpy.typing as npt import polars as pl from ml4t.specs import FeedSpec, TimestampSemantics @@ -164,6 +166,50 @@ def _validate_session_grid( ) +def scheduled_session_indices( + data: pl.DataFrame, + *, + feed_spec: FeedSpec, + calendar: str, + timestamp_col: str, +) -> npt.NDArray[np.int64]: + """Map a validated, sorted single-asset grid to actual schedule ordinals.""" + from ml4t.engineer.labeling.calendar import PandasMarketCalendar + + if data.is_empty(): + return np.empty(0, dtype=np.int64) + labels = feed_spec.timestamp_semantics == TimestampSemantics.SESSION_LABEL + values = data[timestamp_col] + if labels: + days = values.dt.date().to_list() + else: + dtype = values.dtype + if not isinstance(dtype, pl.Datetime): + raise ValueError("Session windows require datetime instants or declared session labels") + if dtype.time_zone is None: + values = values.dt.replace_time_zone( + feed_spec.timezone, ambiguous="raise", non_existent="raise" + ) + values = values.dt.convert_time_zone("UTC") + days = values.dt.date().to_list() + adapter = PandasMarketCalendar(calendar) + schedule = adapter._calendar.schedule( + start_date=min(days) - timedelta(days=7), end_date=max(days) + timedelta(days=7) + ) + if labels: + indices = {label.date(): index for index, label in enumerate(schedule.index)} + return np.array([indices[day] for day in days], dtype=np.int64) + timestamps = values.dt.epoch("us").to_numpy() + opened = np.array([stamp.value // 1000 for stamp in schedule["market_open"]], dtype=np.int64) + side = ( + "left" + if feed_spec.bar_label == "right" + or feed_spec.timestamp_semantics == TimestampSemantics.BAR_CLOSE + else "right" + ) + return np.asarray(np.searchsorted(opened, timestamps, side=side) - 1, dtype=np.int64) + + def validate_bar_grid( data: pl.DataFrame | pl.LazyFrame, *, diff --git a/src/ml4t/engineer/labeling/AGENTS.md b/src/ml4t/engineer/labeling/AGENTS.md index b37031b..24c47bc 100644 --- a/src/ml4t/engineer/labeling/AGENTS.md +++ b/src/ml4t/engineer/labeling/AGENTS.md @@ -78,8 +78,11 @@ observation-row mode without timestamp sorting or provider loading. Never turn t opt-out into a continuous schedule. Config serialization must preserve declaration presence. Named schedules segment actual sessions/breaks. Observation counts are not expected-bar -counts; expected-bar mode validates the declared grid before labeling. Session-count -windows are unsupported, and elapsed durations remain distinct from count horizons. +counts; expected-bar and session modes validate the declared grid before labeling. +Session-count horizons expire at the last available bar of the Nth following scheduled +session, censored at observed coverage end. They must not be split at lunch or overnight. +Daily session-label rows preserve date identifiers and can span whole-session rows; +elapsed durations need instants and must reject session-label identifiers. Right-labeled bars can end at a break start; event timestamps at that instant are off-session. Standalone numerical labeling functions retain their legacy chronological row/duration diff --git a/src/ml4t/engineer/labeling/calendar.py b/src/ml4t/engineer/labeling/calendar.py index 3af122a..80f79ff 100644 --- a/src/ml4t/engineer/labeling/calendar.py +++ b/src/ml4t/engineer/labeling/calendar.py @@ -13,6 +13,7 @@ from inspect import signature from typing import TYPE_CHECKING, Any, Protocol +import numpy as np import polars as pl from ml4t.specs import UNSET_CALENDAR, CalendarUnset, FeedSpec, TimestampSemantics, WindowUnit @@ -395,6 +396,60 @@ def _segment_labels( return result +def _session_window_labels( + data: pl.DataFrame, + config: LabelingConfig, + feed: FeedSpec, + calendar: str, + price_col: str, + timestamp_col: str, + group_cols: list[str], +) -> pl.DataFrame: + from ml4t.engineer.core.bar_grid import scheduled_session_indices + + sorted_data = data.sort([*group_cols, timestamp_col]) + frames = ( + sorted_data.partition_by(group_cols, maintain_order=True) if group_cols else [sorted_data] + ) + frames = frames or [sorted_data] + holding_col = _temporary_column_name(data.columns, "__ml4t_session_holding_bars") + results = [] + for frame in frames: + session_ids = scheduled_session_indices( + frame, feed_spec=feed, calendar=calendar, timestamp_col=timestamp_col + ) + horizon = config.max_holding_period + if isinstance(horizon, int): + counts = np.full(frame.height, horizon, dtype=np.float64) + elif isinstance(horizon, str) and horizon in frame.columns: + counts = np.asarray(frame[horizon].to_numpy(), dtype=np.float64) + else: + raise ValueError("Session holding periods require a positive integer or count column") + if ( + not np.isfinite(counts).all() + or np.any(counts <= 0) + or np.any(counts != np.floor(counts)) + ): + raise ValueError("Session holding periods must contain finite positive integral counts") + # A deadline beyond observed coverage is censored at the final available bar, + # as in the existing count-horizon engine. Clip before integer conversion. + bounded_counts = ( + np.minimum(counts, session_ids[-1] + 1).astype(np.int64) + if frame.height + else np.empty(0, dtype=np.int64) + ) + exits = np.searchsorted(session_ids, session_ids + bounded_counts, side="right") - 1 + periods = np.maximum(1, exits - np.arange(frame.height)) + prepared = frame.with_columns(pl.Series(holding_col, periods)) + numerical_config = config.model_copy(update={"max_holding_period": holding_col}) + results.append( + _segment_labels(prepared, numerical_config, price_col, [], timestamp_col).drop( + holding_col + ) + ) + return pl.concat(results, how="vertical") + + def calendar_aware_labels( data: pl.DataFrame, config: LabelingConfig, @@ -407,10 +462,10 @@ def calendar_aware_labels( feed_spec: FeedSpec | Mapping[str, Any] | None = None, window_unit: WindowUnit | str | None = None, ) -> pl.DataFrame: - """Apply triple-barrier labeling with session awareness. + """Apply triple-barrier labeling with declared calendar and horizon units. - Splits data by trading sessions and applies labeling within each session. - This prevents labels from spanning session gaps (maintenance, overnight, holidays). + Intraday count/elapsed compatibility modes stop at breaks. Session-count + horizons span scheduled sessions, and daily labels count whole-session rows. Parameters ---------- @@ -437,7 +492,8 @@ def calendar_aware_labels( Count windows default to expected_bar for shared feeds and omitted selectors; a complete declared grid is required. Explicit observation counts and elapsed duration horizons remain distinct. Legacy explicit selectors without FeedSpec - retain observation counts. Session-count windows are not implemented. + retain observation counts. Session counts expire on the final available + bar of the Nth following scheduled session, or at observed coverage end. Returns ------- @@ -478,7 +534,8 @@ def calendar_aware_labels( - Uses pandas_market_calendars for all string calendar names - Supports 200+ global calendars + product-specific futures calendars - See pandas_market_calendars.get_calendar_names() for available calendars - - Labels that would span session breaks are truncated at the break + - Intraday observation/expected-bar and legacy duration windows stop at breaks + - Session-count windows cross breaks and use the same numerical barrier engine - This may result in more timeout labels near session closes - Rows outside an explicit calendar's trading times are rejected - Without FeedSpec, legacy naive calendar timestamps are interpreted as UTC @@ -528,8 +585,6 @@ def calendar_aware_labels( raise ValueError( "calendar=None requires observation-count holding periods and window_unit='observation'" ) - if unit == WindowUnit.SESSION: - raise ValueError("session-count labeling windows are not supported") if (unit == WindowUnit.ELAPSED) != duration_horizon: raise ValueError( "elapsed windows require duration holding periods; count windows require observation/expected_bar" @@ -557,8 +612,9 @@ def calendar_aware_labels( if selected is None: return _segment_labels(data, config, resolved_price_col, resolved_group_cols) - if feed.timestamp_semantics == TimestampSemantics.SESSION_LABEL: - raise ValueError("session_label timestamps are not intraday labeling instants") + daily_labels = feed.timestamp_semantics == TimestampSemantics.SESSION_LABEL + if daily_labels and unit == WindowUnit.ELAPSED: + raise ValueError("session_label identifiers cannot define an elapsed horizon") if declared_feed is not None and feed.timestamp_semantics is None: raise ValueError("Calendar-aware FeedSpec requires declared timestamp_semantics") if declared_feed is not None and selected == "auto": @@ -569,14 +625,15 @@ def calendar_aware_labels( dtype = data.schema[resolved_ts_col] if ( declared_feed is not None + and not daily_labels and isinstance(dtype, pl.Datetime) and dtype.time_zone is None and feed.timezone is None ): raise ValueError("Calendar-aware FeedSpec requires timezone metadata for naive timestamps") - if unit == WindowUnit.EXPECTED_BAR: + if unit in {WindowUnit.EXPECTED_BAR, WindowUnit.SESSION} or daily_labels: if not isinstance(selected, str) or selected == "auto": - raise ValueError("expected_bar windows require an authoritative named calendar") + raise ValueError("Scheduled windows require an authoritative named calendar") from ml4t.engineer.core.bar_grid import validate_declared_grid validate_declared_grid( @@ -586,6 +643,19 @@ def calendar_aware_labels( timestamp_col=resolved_ts_col, group_cols=resolved_group_cols, ) + if unit == WindowUnit.SESSION: + assert isinstance(selected, str) + return _session_window_labels( + data, config, feed, selected, resolved_price_col, resolved_ts_col, resolved_group_cols + ) + if daily_labels: + return _segment_labels( + data.sort([*resolved_group_cols, resolved_ts_col]), + config, + resolved_price_col, + resolved_group_cols, + resolved_ts_col, + ) # Create calendar instance if string provided if isinstance(selected, str): diff --git a/tests/test_labeling_session_windows.py b/tests/test_labeling_session_windows.py new file mode 100644 index 0000000..e08e31c --- /dev/null +++ b/tests/test_labeling_session_windows.py @@ -0,0 +1,218 @@ +from datetime import UTC, date, datetime, time, timedelta +from io import BytesIO +from zoneinfo import ZoneInfo + +import polars as pl +import pytest +from ml4t.specs import FeedSpec + +from ml4t.engineer.config import LabelingConfig +from ml4t.engineer.labeling import calendar_aware_labels + + +def _feed(**kwargs): + return FeedSpec( + time_contract_version=2, + calendar="NYSE", + timestamp_semantics="session_label", + data_frequency="1d", + bar_type="time", + **kwargs, + ) + + +def _config(horizon=1, **kwargs): + values = { + "upper_barrier": None, + "lower_barrier": None, + "max_holding_period": horizon, + "window_unit": "session", + } + values.update(kwargs) + return LabelingConfig.triple_barrier(**values) + + +@pytest.mark.parametrize("representation", ["date", "naive", "UTC", "Asia/Tokyo"]) +@pytest.mark.parametrize("sessions,exits", [(1, [1, 2, 3, 4, 4]), (2, [2, 3, 4, 4, 4])]) +def test_session_labels_count_scheduled_sessions_not_elapsed_holiday_days( + representation, sessions, exits +): + days = [date(2025, 7, day) for day in (2, 3, 7, 8, 9)] + timestamps = days + if representation != "date": + zone = None if representation == "naive" else ZoneInfo(representation) + timestamps = [datetime.combine(day, time(0, 15), tzinfo=zone) for day in days] + prices = pl.DataFrame({"timestamp": timestamps, "close": [100.0, 101.0, 102.0, 103.0, 104.0]}) + buffer = BytesIO() + prices.write_parquet(buffer) + buffer.seek(0) + restored = pl.read_parquet(buffer) + result = calendar_aware_labels(restored.reverse(), _config(sessions), feed_spec=_feed()) + assert result.select(prices.columns).equals(prices) + assert result["label_time"].to_list() == [timestamps[index] for index in exits] + assert result["label_bars"].to_list() == [exit - entry for entry, exit in enumerate(exits)] + assert result["label_return"].to_list() == pytest.approx( + [prices["close"][exit] / prices["close"][entry] - 1 for entry, exit in enumerate(exits)] + ) + assert result["barrier_hit"].to_list() == ["time"] * 5 + + +@pytest.mark.parametrize("unit", ["expected_bar", "observation"]) +def test_daily_count_horizon_crosses_valid_sessions_instead_of_zero_length_segments(unit): + timestamps = [date(2025, 7, day) for day in (3, 7, 8)] + prices = pl.DataFrame({"timestamp": timestamps, "close": [100.0, 101.0, 103.0]}) + result = calendar_aware_labels(prices, _config(window_unit=unit), feed_spec=_feed()) + assert result["label_return"].to_list() == pytest.approx([0.01, 2 / 101, 0.0]) + assert result["label_time"].to_list() == [timestamps[1], timestamps[2], timestamps[2]] + + +def _intraday(): + from ml4t.engineer.core.bar_grid import _expected_bar_labels + from ml4t.engineer.labeling.calendar import PandasMarketCalendar + + timestamps = list( + _expected_bar_labels( + PandasMarketCalendar("NYSE"), + datetime(2025, 7, 2, tzinfo=UTC), + datetime(2025, 7, 9, tzinfo=UTC), + timedelta(hours=1), + "right", + True, + ) + ) + prices = pl.DataFrame( + {"timestamp": timestamps, "close": [100.0 + i for i in range(len(timestamps))]} + ) + feed = FeedSpec( + time_contract_version=2, + calendar="NYSE", + timestamp_semantics="bar_close", + data_frequency="1h", + timezone="UTC", + bar_type="time", + bar_grid_anchor="session_open", + bar_label="right", + partial_bar_policy="include", + ) + return prices, feed + + +def test_intraday_session_horizon_reaches_next_session_close_not_lunch_or_overnight_break(): + prices, feed = _intraday() + result = calendar_aware_labels(prices, _config(), feed_spec=feed) + # July 3 closes at 17:00 UTC; July 4 is closed. July 7 closes at 20:00 UTC. + july2 = result.filter(pl.col("timestamp").dt.date() == date(2025, 7, 2)) + july3 = result.filter(pl.col("timestamp").dt.date() == date(2025, 7, 3)) + assert july2["label_time"].to_list() == [datetime(2025, 7, 3, 17, tzinfo=UTC)] * 7 + assert july3["label_time"].to_list() == [datetime(2025, 7, 7, 20, tzinfo=UTC)] * 4 + assert july3["label_bars"].to_list() == [10, 9, 8, 7] + assert result["label_return"][0] == pytest.approx(110 / 100 - 1) + + +def test_missing_entire_scheduled_session_is_rejected_before_any_label_or_input_change(): + prices, feed = _intraday() + incomplete = prices.filter(pl.col("timestamp").dt.date() != date(2025, 7, 3)) + before = incomplete.clone() + with pytest.raises(ValueError, match="missing scheduled bar.*2025-07-03"): + calendar_aware_labels(incomplete, _config(), feed_spec=feed) + assert incomplete.equals(before) + daily = pl.DataFrame( + {"timestamp": [date(2025, 7, 2), date(2025, 7, 7)], "close": [100.0, 103.0]} + ) + with pytest.raises(ValueError, match="missing scheduled session.*2025-07-03"): + calendar_aware_labels(daily, _config(), feed_spec=_feed()) + + +@pytest.mark.parametrize("suffix", ["json", "yaml"]) +def test_session_config_handoff_retains_panel_sparse_events_and_existing_barrier_kernel( + tmp_path, suffix +): + days = [date(2025, 7, day) for day in (2, 3, 7, 8)] + first = pl.DataFrame( + { + "timestamp": days, + "close": [100.0, 101.0, 104.0, 103.0], + "symbol": "A", + "event_time": [days[0], None, days[2], None], + } + ) + second = first.with_columns((pl.col("close") * 2).alias("close"), pl.lit("B").alias("symbol")) + config = _config(2, upper_barrier=0.02, feed_spec=_feed()) + path = tmp_path / f"session.{suffix}" + getattr(config, f"to_{suffix}")(path) + loaded = getattr(LabelingConfig, f"from_{suffix}")(path) + result = calendar_aware_labels(pl.concat([first, second]).reverse(), loaded) + for symbol in ("A", "B"): + asset = result.filter(pl.col("symbol") == symbol) + assert asset["label"].to_list() == [1, None, 0, None] + assert asset["label_time"].to_list() == [days[2], None, days[3], None] + assert asset["label_bars"].to_list() == [2, None, 1, None] + assert asset["barrier_hit"].to_list() == ["upper", None, "time", None] + assert loaded.to_dict()["window_unit"] == "session" + + +@pytest.mark.parametrize("unit", ["session", "elapsed"]) +def test_none_never_becomes_a_continuous_calendar_to_satisfy_temporal_labels(unit): + prices = pl.DataFrame({"close": [100.0, 101.0]}) + with pytest.raises(ValueError, match="observation"): + calendar_aware_labels(prices, _config(), calendar=None, window_unit=unit) + + +def test_elapsed_horizon_rejects_session_identifiers_instead_of_inventing_midnight_instants(): + prices = pl.DataFrame( + {"timestamp": [date(2025, 7, 3), date(2025, 7, 7)], "close": [100.0, 101.0]} + ) + config = LabelingConfig.triple_barrier(max_holding_period="1d", window_unit="elapsed") + with pytest.raises(ValueError, match="session_label.*elapsed"): + calendar_aware_labels(prices, config, feed_spec=_feed()) + + +@pytest.mark.parametrize("counts", [[0, 1, 1], [1.5, 1.0, 1.0], [float("nan"), 1.0, 1.0]]) +def test_session_count_columns_reject_non_positive_non_integral_or_missing_counts(counts): + prices = pl.DataFrame( + { + "timestamp": [date(2025, 7, day) for day in (3, 7, 8)], + "close": [100.0, 101.0, 103.0], + "horizon": counts, + } + ) + with pytest.raises(ValueError, match="finite positive integral"): + calendar_aware_labels(prices, _config("horizon"), feed_spec=_feed()) + + +def test_per_event_session_counts_and_temporary_column_collisions_preserve_original_data(): + prices = pl.DataFrame( + { + "timestamp": [date(2025, 7, day) for day in (2, 3, 7, 8)], + "close": [100.0, 101.0, 103.0, 105.0], + "horizon": [2, 1, 1, 1], + "__ml4t_session_holding_bars": [19] * 4, + } + ) + result = calendar_aware_labels(prices, _config("horizon"), feed_spec=_feed()) + assert result["label_bars"].to_list() == [2, 1, 1, 0] + assert result.select(prices.columns).equals(prices) + + +def test_overnight_cme_daily_session_labels_do_not_shift_to_the_prior_civil_date(): + days = [date(2025, 7, day) for day in (7, 8, 9)] + prices = pl.DataFrame({"timestamp": days, "close": [100.0, 101.0, 103.0]}) + result = calendar_aware_labels(prices, _config(), feed_spec=_feed(), calendar="CME_Equity") + assert result["label_time"].to_list() == [days[1], days[2], days[2]] + assert result["label_return"].to_list() == pytest.approx([0.01, 2 / 101, 0.0]) + + +def test_explicit_continuous_session_counts_include_weekends_without_becoming_row_mode(): + from dataclasses import replace + + days = [date(2025, 7, day) for day in (4, 5, 6, 7)] + prices = pl.DataFrame({"timestamp": days, "close": [100.0, 101.0, 103.0, 105.0]}) + result = calendar_aware_labels(prices, _config(2), feed_spec=replace(_feed(), calendar="24/7")) + assert result["label_time"].to_list() == [days[2], days[3], days[3], days[3]] + with pytest.raises(ValueError, match="missing scheduled session"): + calendar_aware_labels( + prices.filter(pl.col("timestamp") != days[1]), + _config(), + calendar="24/7", + feed_spec=_feed(), + ) diff --git a/tests/test_labeling_time_contract.py b/tests/test_labeling_time_contract.py index 7a85493..05d38e7 100644 --- a/tests/test_labeling_time_contract.py +++ b/tests/test_labeling_time_contract.py @@ -183,7 +183,7 @@ def test_expected_bar_labels_reject_price_outage_before_horizon_interpretation() @pytest.mark.parametrize( "declaration,error", [ - ({"timestamp_semantics": "session_label"}, "session_label"), + ({"timestamp_semantics": "session_label"}, "declared bar grid"), ({"timestamp_semantics": "event_time"}, "timezone"), ], ) From 8cd232dfda58f6aa6b1dc7141eb5c67459645da4 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 18:32:39 -0400 Subject: [PATCH 17/28] feat(api): distinguish causal session and elapsed feature windows (#76) --- docs/user-guide/features.md | 54 +++- src/ml4t/engineer/api.py | 56 +++- src/ml4t/engineer/core/temporal_windows.py | 155 ++++++++++ src/ml4t/engineer/features/AGENTS.md | 7 +- tests/test_api_bar_grid.py | 9 +- tests/test_api_temporal_windows.py | 333 +++++++++++++++++++++ 6 files changed, 595 insertions(+), 19 deletions(-) create mode 100644 src/ml4t/engineer/core/temporal_windows.py create mode 100644 tests/test_api_temporal_windows.py diff --git a/docs/user-guide/features.md b/docs/user-guide/features.md index ba41036..cb5ffe0 100644 --- a/docs/user-guide/features.md +++ b/docs/user-guide/features.md @@ -110,8 +110,8 @@ labels. Date values and datetime representations identify the displayed session timezone conversion. Validation checks every scheduled session between each asset's first and last label, including completely missing sessions. A named `24/7` schedule includes weekends; an NYSE schedule does not. No prices are filled or rows inserted. A daily frequency alone does -not turn `event_time` instants into session labels. Session-count and elapsed-duration feature -windows remain unsupported; they fail rather than being treated as expected-bar counts. +not turn `event_time` instants into session labels. Temporal feature windows below use the +same completeness checks; choosing a unit does not permit missing scheduled observations. ```python @@ -206,6 +206,56 @@ This does not change legacy Specs bridges or the numerical feature kernels. Neither means sessions or elapsed hours. A shortened final bar still counts as one expected bar when `partial_bar_policy="include"`. Annualization remains a separate convention: for example, `realized_volatility(trading_periods=252)` retains its documented numerical default. + +### Session and elapsed feature windows + +`sma`, `stddev` and `realized_volatility` accept `window_unit="session"` or `"elapsed"`. +They retain original bars and row counts. They do not first aggregate to session closes. +For sessions, `params.period=N` includes the current session's observations through the +current row and the preceding N-1 scheduled sessions. Earlier rows of the first N-1 +observed sessions are null. July 4 holidays and lunch breaks do not create extra sessions. +A one-session average of intraday prices100/110 is therefore100 at the first row and105 +at the second row, not the session's eventual closing price110 at both rows. + +Elapsed periods use a positive duration such as `params.period="2h"` and the physical +UTC interval `(timestamp - duration, timestamp]`, including through DST changes. +Outputs remain null until observed coverage spans the requested duration. A half-hour +final bar can put three observations inside two elapsed hours. Daily `session_label` +identifiers cannot define elapsed windows; they do support session-count windows. +Windows with insufficient samples for the numerical statistic also remain null. +Annualization parameters and all other supported feature parameters retain their +numerical definitions. Recursive EMA/RSI and other unqualified window definitions +reject temporal units explicitly rather than silently resetting or reseeding. + + +```python +from datetime import date, datetime, timedelta, timezone + +import polars as pl +from ml4t.engineer import compute_features +from ml4t.specs import FeedSpec + +daily = FeedSpec(time_contract_version=2, calendar="NYSE", data_frequency="1d", + timestamp_semantics="session_label", bar_type="time") +values = [100.0, 101.0, 104.0] +prices = pl.DataFrame({ + "timestamp": [date(2025, 7, day) for day in (2, 3, 7)], + "open": values, "high": values, "low": values, "close": values, "volume": 1.0, +}) +result = compute_features(prices, [{"name": "sma", "params": {"period": 2}}], + feed_spec=daily, window_unit="session") +assert result["sma"].to_list() == [None, 100.5, 102.5] + +hourly = FeedSpec(time_contract_version=2, calendar="24/7", data_frequency="1h", + timestamp_semantics="event_time", timezone="UTC", bar_type="time", + bar_grid_anchor="session_open", bar_label="left", partial_bar_policy="include") +prices = prices.with_columns(pl.Series("timestamp", [ + datetime(2025, 7, 5, tzinfo=timezone.utc) + timedelta(hours=i) for i in range(3) +])) +result = compute_features(prices, [{"name": "sma", "params": {"period": "2h"}}], + feed_spec=hourly, window_unit="elapsed") +assert result["sma"].to_list() == [None, None, 102.5] +``` No calendar-derived annualization factor is silently substituted. ### Equity schedule helpers diff --git a/src/ml4t/engineer/api.py b/src/ml4t/engineer/api.py index f8792b7..8bec54d 100644 --- a/src/ml4t/engineer/api.py +++ b/src/ml4t/engineer/api.py @@ -21,7 +21,7 @@ from typing import Any, TypedDict import polars as pl -from ml4t.specs import UNSET_CALENDAR, CalendarUnset, FeedSpec, WindowUnit +from ml4t.specs import UNSET_CALENDAR, CalendarUnset, FeedSpec, TimestampSemantics, WindowUnit try: import yaml @@ -120,10 +120,13 @@ def compute_features( Explicit None selects observation rows in input order, without schedule checks. feed_spec : FeedSpec | Mapping | None Shared input schema and temporal declarations. Calendar-aware execution requires - version 2, fixed intraday frequency, timestamp semantics and an explicit bar grid. + version 2, declared frequency and timestamp semantics. Intraday feeds also + require an explicit bar grid; daily feeds identify session labels. window_unit : WindowUnit | str | None - Defaults to observation in row mode or expected_bar with a calendar. Session - and elapsed windows are not implemented by these row-based numerical kernels. + Defaults to observation in row mode or expected_bar with a calendar. For sma, + stddev and realized_volatility, session periods count scheduled sessions and + elapsed periods are positive duration strings/timedeltas over original bars. + Recursive and multi-window features reject these temporal units explicitly. Returns ------- @@ -212,8 +215,19 @@ def compute_features( ) if resolved_calendar is None and unit != WindowUnit.OBSERVATION: raise ValueError("calendar=None requires window_unit='observation'") - if resolved_calendar is not None and unit != WindowUnit.EXPECTED_BAR: - raise ValueError("Calendar-aware features support only window_unit='expected_bar'") + if resolved_calendar is not None and unit == WindowUnit.OBSERVATION: + raise ValueError("Calendar-aware features require expected_bar, session or elapsed windows") + temporal = unit in {WindowUnit.SESSION, WindowUnit.ELAPSED} + if ( + unit == WindowUnit.ELAPSED + and contract.timestamp_semantics == TimestampSemantics.SESSION_LABEL + ): + raise ValueError("session_label identifiers cannot define elapsed feature windows") + if temporal: + from ml4t.engineer.core.temporal_windows import temporal_period + + for spec in feature_specs: + temporal_period(spec["name"], spec["params"], unit) if not feature_specs and resolved_calendar is None: return data @@ -255,13 +269,29 @@ def compute_features( # Execute features in order result = data.sort(sort_cols) if resolved_calendar is not None and sort_cols else data for spec in execution_order: - result = _execute_feature( - result, - feature_name=spec["name"], - params=spec["params"], - output_name=spec["output"], - group_cols=group_cols, - ) + if temporal: + from ml4t.engineer.core.temporal_windows import execute_temporal_feature + + assert resolved_timestamp_col is not None and resolved_calendar is not None + result = execute_temporal_feature( + result, + feature_name=spec["name"], + params=spec["params"], + output_name=spec["output"], + group_cols=group_cols, + timestamp_col=resolved_timestamp_col, + feed=contract, + calendar=resolved_calendar, + period=temporal_period(spec["name"], spec["params"], unit), + ) + else: + result = _execute_feature( + result, + feature_name=spec["name"], + params=spec["params"], + output_name=spec["output"], + group_cols=group_cols, + ) return result diff --git a/src/ml4t/engineer/core/temporal_windows.py b/src/ml4t/engineer/core/temporal_windows.py new file mode 100644 index 0000000..2199c15 --- /dev/null +++ b/src/ml4t/engineer/core/temporal_windows.py @@ -0,0 +1,155 @@ +"""Causal temporal bounds for finite-window registered features.""" + +import inspect +from datetime import timedelta +from typing import Any + +import numpy as np +import numpy.typing as npt +import polars as pl +from ml4t.specs import FeedSpec, TimestampSemantics, WindowUnit + +from ml4t.engineer.core.bar_grid import scheduled_session_indices +from ml4t.engineer.core.registry import get_registry + +_MINIMUM_PERIODS = {"sma": 1, "stddev": 1, "realized_volatility": 2} + + +def temporal_period(feature_name: str, params: dict[str, Any], unit: WindowUnit) -> int | timedelta: + """Validate temporal support without executing any feature.""" + if feature_name not in _MINIMUM_PERIODS: + raise ValueError( + f"Feature {feature_name!r} does not support temporal windows; " + f"supported finite-window features are {sorted(_MINIMUM_PERIODS)}. " + "Recursive and multi-window definitions are not converted to row counts." + ) + metadata = get_registry().get(feature_name) + assert metadata is not None + period = params.get( + "period", + metadata.parameters.get( + "period", inspect.signature(metadata.func).parameters["period"].default + ), + ) + if unit == WindowUnit.SESSION: + if isinstance(period, bool) or not isinstance(period, int) or period <= 0: + raise ValueError("Session feature period must be a positive integer session count") + return period + from ml4t.engineer.labeling.utils import parse_duration + + if isinstance(period, str): + try: + period = parse_duration(period) + except ValueError as exc: + raise ValueError("Elapsed feature period must be a positive fixed duration") from exc + if not isinstance(period, timedelta) or period <= timedelta(0): + raise ValueError( + "Elapsed feature period must be a positive fixed duration, not a row count" + ) + return period + + +def _window_sizes( + data: pl.DataFrame, + period: int | timedelta, + feed: FeedSpec, + calendar: str, + timestamp_col: str, +) -> tuple[npt.NDArray[np.int64], npt.NDArray[np.bool_]]: + positions = np.arange(data.height, dtype=np.int64) + if data.is_empty(): + return positions, np.empty(0, dtype=np.bool_) + if isinstance(period, int): + sessions = scheduled_session_indices( + data, feed_spec=feed, calendar=calendar, timestamp_col=timestamp_col + ) + # Counts above observed coverage cannot produce a complete window. Bound + # before NumPy integer arithmetic so arbitrarily large Python counts are safe. + if period > int(sessions[-1] - sessions[0]) + 1: + return positions + 1, np.zeros(data.height, dtype=np.bool_) + starts = np.searchsorted(sessions, sessions - period + 1, side="left") + complete = sessions - sessions[0] >= period - 1 + else: + if feed.timestamp_semantics == TimestampSemantics.SESSION_LABEL: + raise ValueError("session_label identifiers cannot define elapsed feature windows") + values = data[timestamp_col] + dtype = values.dtype + assert isinstance(dtype, pl.Datetime) + if dtype.time_zone is None: + assert feed.timezone is not None + values = values.dt.replace_time_zone( + feed.timezone, ambiguous="raise", non_existent="raise" + ) + timestamps = values.dt.convert_time_zone("UTC").dt.epoch("us").to_numpy() + duration = (period.days * 86400 + period.seconds) * 1_000_000 + period.microseconds + if duration > int(timestamps[-1] - timestamps[0]): + return positions + 1, np.zeros(data.height, dtype=np.bool_) + starts = np.searchsorted(timestamps, timestamps - duration, side="right") + complete = timestamps - timestamps[0] >= duration + return np.asarray(positions - starts + 1, dtype=np.int64), complete + + +def execute_temporal_feature( + data: pl.DataFrame | pl.LazyFrame, + *, + feature_name: str, + params: dict[str, Any], + output_name: str, + group_cols: list[str], + timestamp_col: str, + feed: FeedSpec, + calendar: str, + period: int | timedelta, +) -> pl.DataFrame | pl.LazyFrame: + """Reuse finite row kernels once per distinct causal window size, without resampling.""" + from ml4t.engineer.api import _execute_feature + + minimum = _MINIMUM_PERIODS[feature_name] + template = _execute_feature( + data.head(0), feature_name, {**params, "period": minimum}, output_name, [] + ) + schema = template.collect_schema() if isinstance(template, pl.LazyFrame) else template.schema + if isinstance(data, pl.LazyFrame): + + def apply(frame: pl.DataFrame) -> pl.DataFrame: + result = execute_temporal_feature( + frame, + feature_name=feature_name, + params=params, + output_name=output_name, + group_cols=group_cols, + timestamp_col=timestamp_col, + feed=feed, + calendar=calendar, + period=period, + ) + assert isinstance(result, pl.DataFrame) + return result + + return data.map_batches( + apply, + schema=schema, + predicate_pushdown=False, + projection_pushdown=False, + slice_pushdown=False, + streamable=False, + ) + output_columns = [name for name in schema if name not in data.columns] + frames = data.partition_by(group_cols, maintain_order=True) if group_cols else [data] + results = [] + for frame in frames or [data]: + sizes, complete = _window_sizes(frame, period, feed, calendar, timestamp_col) + outputs = { + name: pl.Series(name, [None] * frame.height, dtype=schema[name]) + for name in output_columns + } + for size in np.unique(sizes[complete & (sizes >= minimum)]): + computed = _execute_feature( + frame, feature_name, {**params, "period": int(size)}, output_name, [] + ) + assert isinstance(computed, pl.DataFrame) + indices = np.flatnonzero(complete & (sizes == size)) + for name in output_columns: + outputs[name] = outputs[name].scatter(indices, computed[name].gather(indices)) + results.append(frame.with_columns(list(outputs.values()))) + return pl.concat(results, how="vertical") diff --git a/src/ml4t/engineer/features/AGENTS.md b/src/ml4t/engineer/features/AGENTS.md index 14c43d8..6435dea 100644 --- a/src/ml4t/engineer/features/AGENTS.md +++ b/src/ml4t/engineer/features/AGENTS.md @@ -60,10 +60,13 @@ result = compute_features( ``` This example explicitly counts observation rows. Omitting `calendar` selects shared -feed metadata, then NYSE, and requires a declared complete intraday grid. Use +feed metadata, then NYSE, and requires a declared complete daily or intraday grid. Use `FeedSpec` declarations for expected bars; do not infer frequency or holidays from gaps. Numerical kernels keep their documented annualization conventions independently of -calendar selection. Session and elapsed feature windows remain unsupported. +calendar selection. `sma`, `stddev` and `realized_volatility` also support session/elapsed +windows through the public API. Reuse their existing finite kernels over causal original +bars, with schedule ordinals for sessions and open-left physical UTC intervals for elapsed +durations. Do not resample, infer missing sessions or reinterpret recursive indicators. ## Trust Signals diff --git a/tests/test_api_bar_grid.py b/tests/test_api_bar_grid.py index 897dbfe..8a1f00f 100644 --- a/tests/test_api_bar_grid.py +++ b/tests/test_api_bar_grid.py @@ -269,8 +269,13 @@ def unexpected(*_args, **_kwargs): def test_calendar_grid_does_not_reinterpret_other_window_units_as_expected_bars(unit): start = datetime(2025, 7, 11, tzinfo=UTC) data = returns(prices([start + timedelta(hours=h) for h in range(8)])) - with pytest.raises(ValueError, match="window_unit='expected_bar'"): - run(data, False, feed_spec=feed("24/7"), window_unit=unit) + if unit is WindowUnit.SESSION: + result = run(data, False, feed_spec=feed("24/7"), window_unit=unit) + assert result["realized_volatility"].is_null().all() + else: + error = "period" if unit is WindowUnit.ELAPSED else "Calendar-aware" + with pytest.raises(ValueError, match=error): + run(data, False, feed_spec=feed("24/7"), window_unit=unit) if unit is WindowUnit.OBSERVATION: assert run(data, False, calendar=None, window_unit=unit).height == 8 else: diff --git a/tests/test_api_temporal_windows.py b/tests/test_api_temporal_windows.py new file mode 100644 index 0000000..c0abc07 --- /dev/null +++ b/tests/test_api_temporal_windows.py @@ -0,0 +1,333 @@ +from datetime import UTC, date, datetime, timedelta +from statistics import mean, pstdev, stdev + +import polars as pl +import pytest +import yaml +from ml4t.specs import FeedSpec + +from ml4t.engineer import compute_features + + +def _prices(): + from ml4t.engineer.core.bar_grid import _expected_bar_labels + from ml4t.engineer.labeling.calendar import PandasMarketCalendar + + timestamps = list( + _expected_bar_labels( + PandasMarketCalendar("NYSE"), + datetime(2025, 7, 2, tzinfo=UTC), + datetime(2025, 7, 9, tzinfo=UTC), + timedelta(hours=1), + "right", + True, + ) + ) + values = [100.0 + index for index in range(len(timestamps))] + return pl.DataFrame( + { + "timestamp": timestamps, + "symbol": "A", + "open": values, + "high": values, + "low": values, + "close": values, + "volume": 1.0, + } + ) + + +def _feed(): + return FeedSpec( + time_contract_version=2, + calendar="NYSE", + timezone="UTC", + entity_col="symbol", + timestamp_semantics="bar_close", + data_frequency="1h", + bar_type="time", + bar_grid_anchor="session_open", + bar_label="right", + partial_bar_policy="include", + ) + + +def _run(prices, period, unit, lazy=False): + checked = compute_features(prices, [], feed_spec=_feed()) + returns = checked.with_columns(pl.col("close").pct_change().over("symbol").alias("returns")) + features = [ + {"name": "sma", "params": {"period": period}}, + {"name": "stddev", "params": {"period": period}}, + {"name": "realized_volatility", "params": {"period": period, "annualize": False}}, + ] + data = returns.lazy() if lazy else returns + result = compute_features(data, features, feed_spec=_feed(), window_unit=unit) + assert isinstance(result, type(data)) + return result.collect() if lazy else result + + +@pytest.mark.parametrize("lazy", [False, True]) +def test_session_feature_windows_use_original_causal_bars_and_actual_shortened_sessions(lazy): + prices = _prices() + result = _run(prices.reverse(), 2, "session", lazy) + assert result.select(prices.columns).equals(prices) + assert result["sma"][:7].to_list() == [None] * 7 + # First July3 bar includes seven July2 bars and itself, not two rows or two closes. + assert result["sma"][7] == pytest.approx(mean(range(100, 108))) + assert result["stddev"][7] == pytest.approx(pstdev(range(100, 108))) + # July3 has only four bars; July4 is closed. First July7 bar sees July3 + itself. + assert result["sma"][11] == pytest.approx(mean(range(107, 112))) + assert result["stddev"][11] == pytest.approx(pstdev(range(107, 112))) + assert result["realized_volatility"][11] == pytest.approx( + stdev([i / (i - 1) - 1 for i in range(107, 112)]) + ) + + +@pytest.mark.parametrize("lazy", [False, True]) +def test_elapsed_feature_windows_use_open_left_utc_duration_not_rows_or_sessions(lazy): + prices = _prices() + result = _run(prices, "2h", "elapsed", lazy) + assert result["sma"][:2].to_list() == [None, None] + assert result["sma"][2] == pytest.approx(101.5) + # First July3 bar has no bar from July2 inside the elapsed duration. + assert result["sma"][7] == pytest.approx(107.0) + assert result["realized_volatility"][7] is None + # The final half-hour July3 bar puts three observations inside two physical hours. + assert result["sma"][10] == pytest.approx(mean([108, 109, 110])) + assert result["stddev"][10] == pytest.approx(pstdev([108, 109, 110])) + assert result["realized_volatility"][10] == pytest.approx( + stdev([108 / 107 - 1, 109 / 108 - 1, 110 / 109 - 1]) + ) + + +@pytest.mark.parametrize("unit,period", [("session", 2), ("elapsed", "2h")]) +def test_temporal_feature_panels_are_isolated_and_future_prices_do_not_enter_earlier_windows( + unit, period +): + first = _prices() + second = first.with_columns( + *[(pl.col(name) * 2).alias(name) for name in ("open", "high", "low", "close")], + pl.lit("B").alias("symbol"), + ) + result = _run(pl.concat([second, first]).reverse(), period, unit, True) + for frame in (first, second): + symbol = frame["symbol"][0] + assert result.filter(pl.col("symbol") == symbol).equals(_run(frame, period, unit)) + changed = first.with_columns( + *[ + pl.when(pl.col("timestamp").dt.date() >= date(2025, 7, 7)) + .then(pl.col(name) * 4) + .otherwise(pl.col(name)) + .alias(name) + for name in ("open", "high", "low", "close") + ] + ) + before = _run(first, period, unit).head(11) + after = _run(changed, period, unit).head(11) + assert before.equals(after) + + +@pytest.mark.parametrize("unit,period", [("session", 2), ("elapsed", "2h")]) +def test_lazy_filter_projection_and_slice_do_not_remove_temporal_warmup_history(unit, period): + prices = _prices() + features = [{"name": "sma", "params": {"period": period}}] + full = compute_features(prices, features, feed_spec=_feed(), window_unit=unit) + predicate = pl.col("timestamp").dt.date() == date(2025, 7, 7) + lazy = compute_features(prices.lazy(), features, feed_spec=_feed(), window_unit=unit) + assert ( + lazy.filter(predicate).select("sma").collect().equals(full.filter(predicate).select("sma")) + ) + assert lazy.slice(7, 4).collect().equals(full.slice(7, 4)) + + +@pytest.mark.parametrize("unit,period", [("session", 2), ("elapsed", "2h")]) +def test_serialized_existing_feature_requests_keep_explicit_temporal_units_and_parameters( + tmp_path, unit, period +): + features = [{"name": "sma", "params": {"period": period}, "output": "average"}] + path = tmp_path / "features.yaml" + path.write_text(yaml.safe_dump({"features": features})) + expected = compute_features(_prices(), features, feed_spec=_feed(), window_unit=unit) + actual = compute_features(_prices(), path, feed_spec=_feed().to_dict(), window_unit=unit) + assert actual.equals(expected) + + +@pytest.mark.parametrize("unit,period", [("session", 2), ("elapsed", "2h")]) +def test_missing_complete_session_is_rejected_before_temporal_feature_returns_are_interpreted( + unit, period +): + prices = _prices().filter(pl.col("timestamp").dt.date() != date(2025, 7, 3)) + before = prices.clone() + with pytest.raises(ValueError, match="missing scheduled bar.*2025-07-03"): + compute_features( + prices, + [{"name": "sma", "params": {"period": period}}], + feed_spec=_feed(), + window_unit=unit, + ) + assert prices.equals(before) + + +@pytest.mark.parametrize("unit,period", [("session", 2), ("elapsed", "2h")]) +@pytest.mark.parametrize("feature", ["ema", "rsi"]) +def test_temporal_units_do_not_silently_redefine_recursive_indicators(feature, unit, period): + with pytest.raises(ValueError, match=f"{feature}.*temporal"): + compute_features( + _prices(), + [{"name": feature, "params": {"period": period}}], + feed_spec=_feed(), + window_unit=unit, + ) + + +@pytest.mark.parametrize( + "unit,period", + [("session", 0), ("session", 1.5), ("session", True), ("elapsed", 2), ("elapsed", "0h")], +) +def test_temporal_feature_periods_reject_unit_mismatches_instead_of_converting_rows(unit, period): + with pytest.raises(ValueError, match="period"): + compute_features( + _prices(), + [{"name": "sma", "params": {"period": period}}], + feed_spec=_feed(), + window_unit=unit, + ) + + +@pytest.mark.parametrize("unit,period", [("session", 2), ("elapsed", "2h")]) +def test_calendar_none_remains_observation_mode_even_when_temporal_windows_are_requested( + unit, period +): + with pytest.raises(ValueError, match="observation"): + compute_features( + _prices(), + [{"name": "sma", "params": {"period": period}}], + calendar=None, + window_unit=unit, + ) + + +@pytest.mark.parametrize("naive", [False, True]) +def test_elapsed_window_counts_physical_hours_through_spring_dst_in_declared_timezone(naive): + timestamps = pl.Series( + "timestamp", [datetime(2025, 3, 9, hour, tzinfo=UTC) for hour in range(5, 11)] + ).dt.convert_time_zone("America/New_York") + if naive: + timestamps = timestamps.dt.replace_time_zone(None) + values = [100.0 + i for i in range(6)] + prices = pl.DataFrame( + { + "timestamp": timestamps, + "close": values, + "open": values, + "high": values, + "low": values, + "volume": 1.0, + } + ) + feed = FeedSpec( + time_contract_version=2, + calendar="24/7", + timezone="America/New_York", + timestamp_semantics="event_time", + data_frequency="1h", + bar_type="time", + bar_grid_anchor="session_open", + bar_label="left", + partial_bar_policy="include", + ) + result = compute_features( + prices, [{"name": "sma", "params": {"period": "2h"}}], feed_spec=feed, window_unit="elapsed" + ) + assert result["sma"].to_list() == [None, None, 101.5, 102.5, 103.5, 104.5] + + +@pytest.mark.parametrize("lazy", [False, True]) +def test_daily_feature_session_windows_count_sessions_without_inventing_intraday_instants(lazy): + values = [100.0, 101.0, 104.0, 105.0] + prices = pl.DataFrame( + { + "timestamp": [date(2025, 7, day) for day in (2, 3, 7, 8)], + "close": values, + "open": values, + "high": values, + "low": values, + "volume": 1.0, + } + ) + feed = FeedSpec( + time_contract_version=2, + calendar="NYSE", + timestamp_semantics="session_label", + data_frequency="1d", + bar_type="time", + ) + result = compute_features( + prices.lazy() if lazy else prices, + [{"name": "sma", "params": {"period": 2}}], + feed_spec=feed, + window_unit="session", + ) + result = result.collect() if lazy else result + assert result["sma"].to_list() == [None, 100.5, 102.5, 104.5] + with pytest.raises(ValueError, match="session_label.*elapsed"): + compute_features( + prices, + [{"name": "sma", "params": {"period": "2h"}}], + feed_spec=feed, + window_unit="elapsed", + ) + + +@pytest.mark.parametrize("lazy", [False, True]) +def test_temporal_windows_preserve_ddof_scale_annualization_and_input_parameter_values(lazy): + from copy import deepcopy + from math import sqrt + + prices = _prices().with_columns(pl.col("close").pct_change().over("symbol").alias("returns")) + features = [ + {"name": "stddev", "params": {"period": 2, "nbdev": 2.0, "ddof": 1}}, + {"name": "realized_volatility", "params": {"period": 2, "trading_periods": 365}}, + {"name": "sma", "params": {"period": 1}, "output": "current_session"}, + {"name": "sma", "params": {"period": 2}, "output": "two_sessions"}, + ] + original = deepcopy(features) + result = compute_features( + prices.lazy() if lazy else prices, features, feed_spec=_feed(), window_unit="session" + ) + result = result.collect() if lazy else result + assert result["stddev"][11] == pytest.approx(2 * stdev(range(107, 112))) + assert result["realized_volatility"][11] == pytest.approx( + stdev([i / (i - 1) - 1 for i in range(107, 112)]) * sqrt(365) + ) + assert result["current_session"][11] == 111.0 + assert result["two_sessions"][11] == 109.0 + assert features == original + + +@pytest.mark.parametrize("unit,period", [("session", 10**100), ("elapsed", "365d")]) +def test_temporal_window_exceeding_available_coverage_stays_null_without_integer_overflow( + unit, period +): + result = compute_features( + _prices(), + [{"name": "sma", "params": {"period": period}}], + feed_spec=_feed(), + window_unit=unit, + ) + assert result["sma"].is_null().all() + + +@pytest.mark.parametrize("lazy", [False, True]) +def test_empty_temporal_feature_input_preserves_typed_columns_and_no_observations(lazy): + prices = _prices().head(0) + result = compute_features( + prices.lazy() if lazy else prices, + [{"name": "sma", "params": {"period": 1}}], + feed_spec=_feed(), + window_unit="session", + ) + result = result.collect() if lazy else result + assert result.height == 0 + assert result.schema["sma"] == pl.Float64 + assert result.select(prices.columns).equals(prices) From c056bbb34882d88159006cb12bd1f50f2980b8ab Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 18:38:41 -0400 Subject: [PATCH 18/28] test(api): retain independent annualization consumer regressions (#76) --- tests/test_api_annualization_contract.py | 208 +++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/test_api_annualization_contract.py diff --git a/tests/test_api_annualization_contract.py b/tests/test_api_annualization_contract.py new file mode 100644 index 0000000..6a3424a --- /dev/null +++ b/tests/test_api_annualization_contract.py @@ -0,0 +1,208 @@ +from datetime import UTC, datetime, timedelta +from math import sqrt +from statistics import mean, stdev + +import numpy as np +import pandas_market_calendars as mcal +import polars as pl +import pytest +import yaml +from ml4t.specs import FeedSpec + +from ml4t.engineer import compute_features + +RETURNS = np.array( + [ + 0.012, + -0.008, + 0.006, + -0.003, + 0.015, + -0.011, + 0.004, + 0.009, + -0.005, + 0.007, + -0.002, + 0.013, + -0.009, + 0.005, + 0.011, + -0.006, + 0.008, + -0.004, + 0.014, + -0.007, + ] +) + + +def prices(times): + frames = [] + for symbol, multiplier in [("A", 1.0), ("B", 1.7)]: + close = 100 * np.cumprod(np.r_[1.0, 1.0 + RETURNS * multiplier]) + frames.append( + pl.DataFrame( + { + "timestamp": times, + "symbol": symbol, + "open": close, + "high": close, + "low": close, + "close": close, + "volume": 1.0, + } + ) + ) + return pl.concat(frames) + + +def feed(calendar): + return FeedSpec( + time_contract_version=2, + calendar=calendar, + entity_col="symbol", + data_frequency="1h", + timestamp_semantics="bar_close", + bar_type="time", + bar_grid_anchor="session_open", + bar_label="right", + partial_bar_policy="include", + ) + + +def actual_times(calendar): + schedule = mcal.get_calendar(calendar).schedule("2025-07-07", "2025-07-17") + return list( + mcal.date_range(schedule, frequency="1h", closed="right", force_close=True)[ + :21 + ].to_pydatetime() + ) + + +def request_file(tmp_path, requests, serialized): + if not serialized: + return requests + path = tmp_path / "features.yaml" + path.write_text(yaml.safe_dump({"features": requests})) + return path + + +def materialize(frame): + return frame.collect() if isinstance(frame, pl.LazyFrame) else frame + + +@pytest.mark.parametrize("calendar", ["NYSE", "24/7"]) +@pytest.mark.parametrize("lazy", [False, True]) +@pytest.mark.parametrize("serialized", [False, True]) +def test_real_price_pipeline_uses_explicit_scale_not_calendar_or_bar_frequency( + tmp_path, calendar, lazy, serialized +): + raw = prices(actual_times(calendar)) + data = raw.lazy() if lazy else raw + checked = compute_features(data, [], feed_spec=feed(calendar)) + returns = checked.with_columns(pl.col("close").pct_change().over("symbol").alias("returns")) + requests = [ + { + "name": "realized_volatility", + "params": {"period": 3, "annualize": False}, + "output": "raw", + }, + {"name": "realized_volatility", "params": {"period": 3}, "output": "legacy_default"}, + ] + requests += [ + { + "name": "realized_volatility", + "params": {"period": 3, "trading_periods": scale}, + "output": f"v{scale}", + } + for scale in (252, 365, 8760) + ] + output = materialize( + compute_features( + returns, request_file(tmp_path, requests, serialized), feed_spec=feed(calendar) + ) + ) + assert output.select(raw.columns).equals(raw) + for symbol in ("A", "B"): + asset = output.filter(pl.col("symbol") == symbol) + values = asset["returns"].drop_nulls().to_list() + expected = stdev(values[-3:]) + assert asset["raw"][-1] == pytest.approx(expected, rel=1e-11) + assert asset["legacy_default"][-1] == pytest.approx(expected * sqrt(252), rel=1e-11) + for scale in (252, 365, 8760): + assert asset[f"v{scale}"][-1] == pytest.approx(expected * sqrt(scale), rel=1e-11) + + +@pytest.mark.parametrize("lazy", [False, True]) +def test_row_mode_annualization_is_invariant_to_irregular_and_reordered_timestamps( + monkeypatch, lazy +): + import sys + + monkeypatch.setitem(sys.modules, "pandas_market_calendars", None) + start = datetime(2025, 7, 11, tzinfo=UTC) + outputs = [] + requests = [ + { + "name": "realized_volatility", + "params": {"period": 3, "trading_periods": scale}, + "output": f"v{scale}", + } + for scale in (252, 365, 8760) + ] + for offsets in [list(range(21)), [i * i for i in range(21)], list(reversed(range(21)))]: + raw = prices([start + timedelta(hours=h) for h in offsets]) + data = raw.lazy() if lazy else raw + returns = data.with_columns(pl.col("close").pct_change().over("symbol").alias("returns")) + result = materialize( + compute_features(returns, requests, feed_spec=feed("NYSE"), calendar=None) + ) + assert result.select(raw.columns).equals(raw) + outputs.append(result.select([f"v{scale}" for scale in (252, 365, 8760)])) + assert outputs[0].equals(outputs[1]) + assert outputs[0].equals(outputs[2]) + + +@pytest.mark.parametrize("scale", [252, 365, 8760]) +@pytest.mark.parametrize("lazy", [False, True]) +@pytest.mark.parametrize("serialized", [False, True]) +def test_public_risk_workflow_retains_explicit_annualization_and_period_risk_free_rate( + tmp_path, scale, lazy, serialized +): + raw = prices(actual_times("24/7")) + data = raw.lazy() if lazy else raw + checked = compute_features(data, [], feed_spec=feed("24/7")) + returns = checked.with_columns(pl.col("close").pct_change().over("symbol").alias("returns")) + requests = [ + { + "name": "risk_adjusted_returns", + "params": {"window": 20, "risk_free_rate": 0.052, "trading_periods": scale}, + "output": "risk", + } + ] + output = materialize( + compute_features( + returns, request_file(tmp_path, requests, serialized), feed_spec=feed("24/7") + ) + ) + assert output.select(raw.columns).equals(raw) + for symbol in ("A", "B"): + asset = output.filter(pl.col("symbol") == symbol) + values = asset["returns"].drop_nulls().to_numpy() + rf = 0.052 / scale + excess = mean(values) - rf + downside = np.minimum(values - rf, 0) + close = asset["close"].to_numpy() + peaks = np.maximum.accumulate(close) + drawdown = abs(np.min((close - peaks) / peaks)) + gains = np.sum(values[values > rf] - rf) + losses = np.sum(rf - values[values <= rf]) + expected = { + "sharpe_ratio": excess / (stdev(values) + 1e-10) * sqrt(scale), + "sortino_ratio": excess / (sqrt(np.mean(downside**2)) + 1e-10) * sqrt(scale), + "calmar_ratio": mean(values) * scale / (drawdown + 1e-10), + "omega_ratio": gains / losses, + } + for metric, value in expected.items(): + assert asset[f"risk_{metric}"][-1] == pytest.approx(value, rel=1e-9) From 0fc80a77b667efa785b1b4d5cd7bff8cabe151d6 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 18:45:19 -0400 Subject: [PATCH 19/28] fix(labeling): preserve portable schemas when price filters remove all rows (#94) --- src/ml4t/engineer/labeling/triple_barrier.py | 4 +- tests/test_labeling_empty_schema.py | 45 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/test_labeling_empty_schema.py diff --git a/src/ml4t/engineer/labeling/triple_barrier.py b/src/ml4t/engineer/labeling/triple_barrier.py index f9ff27e..612de39 100644 --- a/src/ml4t/engineer/labeling/triple_barrier.py +++ b/src/ml4t/engineer/labeling/triple_barrier.py @@ -327,7 +327,7 @@ def _build_labeling_result( "label_return": pl.Series(label_returns, dtype=pl.Float64), "label_bars": pl.Series(bar_durations, dtype=pl.Int64), "label_duration": pl.Series(time_durations), - "barrier_hit": pl.Series(barrier_hit), + "barrier_hit": pl.Series(barrier_hit, dtype=pl.String), } if calc_uniq: columns_to_add["label_uniqueness"] = pl.Series(uniqueness, dtype=pl.Float64) @@ -534,7 +534,7 @@ def triple_barrier_labels( 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_frames = sorted_data.partition_by(group_cols, maintain_order=True) or [sorted_data] grouped_results = [ _triple_barrier_labels_single_group( diff --git a/tests/test_labeling_empty_schema.py b/tests/test_labeling_empty_schema.py new file mode 100644 index 0000000..8244e9c --- /dev/null +++ b/tests/test_labeling_empty_schema.py @@ -0,0 +1,45 @@ +from datetime import UTC, date, datetime +from io import BytesIO + +import polars as pl +import pytest +from ml4t.specs import FeedSpec + +from ml4t.engineer.config import LabelingConfig +from ml4t.engineer.labeling import calendar_aware_labels, triple_barrier_labels + + +@pytest.mark.parametrize("kind", ["date", "instant"]) +@pytest.mark.parametrize("mode", ["session", "expected_bar", "observation", "standalone"]) +@pytest.mark.parametrize("sparse_events", [False, True]) +def test_filtering_all_prices_still_produces_a_portable_typed_label_table( + kind, mode, sparse_events +): + days = [date(2025, 7, day) for day in (3, 7)] + if kind == "instant": + days = [datetime(2025, 7, day, tzinfo=UTC) for day in (3, 7)] + prices = pl.DataFrame({"timestamp": days, "close": [100.0, 101.0], "symbol": "A"}) + if sparse_events: + prices = prices.with_columns(pl.col("timestamp").alias("event_time")) + empty = prices.filter(pl.col("close") > 1000) + config = LabelingConfig.triple_barrier(max_holding_period=1) + if mode == "standalone": + result = triple_barrier_labels(empty, config, timestamp_col="timestamp") + else: + feed = FeedSpec( + time_contract_version=2, + calendar="NYSE", + timestamp_semantics="session_label", + data_frequency="1d", + bar_type="time", + entity_col="symbol", + ) + result = calendar_aware_labels(empty, config, feed_spec=feed, window_unit=mode) + assert result.height == 0 + assert result.select(empty.columns).schema == empty.schema + assert result.schema["barrier_hit"] == pl.String + assert result.schema["label_time"] == prices.schema["timestamp"] + buffer = BytesIO() + result.write_parquet(buffer) + buffer.seek(0) + assert pl.read_parquet(buffer).schema == result.schema From 74b5f594a909c659424355e07b1daccd5749496a Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 18:46:58 -0400 Subject: [PATCH 20/28] fix(labeling): keep session deadlines before excluded short-session gaps (#94) --- src/ml4t/engineer/labeling/calendar.py | 19 ++++++--- src/ml4t/engineer/labeling/triple_barrier.py | 4 ++ tests/test_labeling_session_windows.py | 45 ++++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/ml4t/engineer/labeling/calendar.py b/src/ml4t/engineer/labeling/calendar.py index 80f79ff..5960f8e 100644 --- a/src/ml4t/engineer/labeling/calendar.py +++ b/src/ml4t/engineer/labeling/calendar.py @@ -407,12 +407,12 @@ def _session_window_labels( ) -> pl.DataFrame: from ml4t.engineer.core.bar_grid import scheduled_session_indices + validate_price_no_nans(data, price_col) sorted_data = data.sort([*group_cols, timestamp_col]) frames = ( sorted_data.partition_by(group_cols, maintain_order=True) if group_cols else [sorted_data] ) frames = frames or [sorted_data] - holding_col = _temporary_column_name(data.columns, "__ml4t_session_holding_bars") results = [] for frame in frames: session_ids = scheduled_session_indices( @@ -439,12 +439,19 @@ def _session_window_labels( else np.empty(0, dtype=np.int64) ) exits = np.searchsorted(session_ids, session_ids + bounded_counts, side="right") - 1 - periods = np.maximum(1, exits - np.arange(frame.height)) - prepared = frame.with_columns(pl.Series(holding_col, periods)) - numerical_config = config.model_copy(update={"max_holding_period": holding_col}) + periods = np.asarray(exits - np.arange(frame.height), dtype=np.int64) results.append( - _segment_labels(prepared, numerical_config, price_col, [], timestamp_col).drop( - holding_col + _triple_barrier_labels_single_group( + frame, + config, + price_col, + None, + None, + None, + timestamp_col, + False, + "returns_uniqueness", + max_holding_bars=periods, ) ) return pl.concat(results, how="vertical") diff --git a/src/ml4t/engineer/labeling/triple_barrier.py b/src/ml4t/engineer/labeling/triple_barrier.py index 612de39..18fc71e 100644 --- a/src/ml4t/engineer/labeling/triple_barrier.py +++ b/src/ml4t/engineer/labeling/triple_barrier.py @@ -350,6 +350,8 @@ def _triple_barrier_labels_single_group( uniqueness_weight_scheme: Literal[ "returns_uniqueness", "uniqueness_only", "returns_only", "equal" ], + *, + max_holding_bars: npt.NDArray[np.int64] | None = None, ) -> pl.DataFrame: """Apply triple barrier labeling to a single asset/group.""" if timestamp_col is not None: @@ -394,6 +396,8 @@ def _triple_barrier_labels_single_group( upper_barriers, lower_barriers, max_periods, sides, trailing_stops = _prepare_barrier_arrays( data, config, event_indices, timestamp_col=timestamp_col ) + if max_holding_bars is not None: + max_periods = max_holding_bars[event_indices] labels, label_indices, label_prices, label_returns, bar_durations = _apply_triple_barrier_nb( closes, opens, diff --git a/tests/test_labeling_session_windows.py b/tests/test_labeling_session_windows.py index e08e31c..90cf153 100644 --- a/tests/test_labeling_session_windows.py +++ b/tests/test_labeling_session_windows.py @@ -216,3 +216,48 @@ def test_explicit_continuous_session_counts_include_weekends_without_becoming_ro calendar="24/7", feed_spec=_feed(), ) + + +@pytest.mark.parametrize("side", ["left", "right"]) +@pytest.mark.parametrize("sparse", [False, True]) +@pytest.mark.parametrize("horizon,first_exit", [(1, 0), (2, 1)]) +def test_barless_short_session_never_pushes_a_session_deadline_into_a_later_session( + side, sparse, horizon, first_exit +): + from ml4t.engineer.core.bar_grid import _expected_bar_labels + from ml4t.engineer.labeling.calendar import PandasMarketCalendar + + timestamps = list( + _expected_bar_labels( + PandasMarketCalendar("NYSE"), + datetime(2025, 7, 2, tzinfo=UTC), + datetime(2025, 7, 9, tzinfo=UTC), + timedelta(hours=5), + side, + False, + ) + ) + assert [stamp.date() for stamp in timestamps] == [date(2025, 7, d) for d in (2, 7, 8)] + prices = pl.DataFrame({"timestamp": timestamps, "close": [100.0, 101.0, 102.0]}) + if sparse: + prices = prices.with_columns(pl.Series("event_time", [timestamps[0], None, timestamps[2]])) + feed = FeedSpec( + time_contract_version=2, + calendar="NYSE", + timezone="UTC", + data_frequency="5h", + timestamp_semantics="bar_close" if side == "right" else "event_time", + bar_type="time", + bar_grid_anchor="session_open", + bar_label=side, + partial_bar_policy="exclude", + ) + result = calendar_aware_labels(prices, _config(horizon), feed_spec=feed) + exits = [first_exit, None if sparse else 2, 2] + assert result["label_time"].to_list() == [ + timestamps[index] if index is not None else None for index in exits + ] + assert result["label_bars"].to_list() == [ + index - entry if index is not None else None for entry, index in enumerate(exits) + ] + assert result["label_return"][0] == pytest.approx(first_exit / 100) From ddc132f3d90b1f54a146e9cfb820ecc5c618901f Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 18:48:21 -0400 Subject: [PATCH 21/28] docs(labeling): execute session horizons and preserve the previous guide anchor (#94) --- docs/user-guide/labeling.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/user-guide/labeling.md b/docs/user-guide/labeling.md index 5ca6dfb..6dc9b13 100644 --- a/docs/user-guide/labeling.md +++ b/docs/user-guide/labeling.md @@ -439,6 +439,8 @@ former pause because trading is continuous until 4:00 PM. See the [CME exchange notice](https://www.cmegroup.com/notices/electronic-trading/2021/06/20210621.html) for the affected equity-index contracts. + + ### Observation rows, expected bars, sessions and elapsed durations Use `calendar=None` for observation-row labeling, including timestamp-indexed data: @@ -495,6 +497,11 @@ Profit, stop and trailing barriers still use the existing triple-barrier calcula The full declared grid is checked before any labels are computed, including whole-session outages. No prices are filled or rows inserted. +With `partial_bar_policy="exclude"`, a shortened session can have no bars. It still +counts as a scheduled session. The exit uses the last available bar no later than the +deadline, even if that is the entry itself with zero holding bars. The calculation +does not extend the deadline into a later session to obtain another observation. + Daily input uses `FeedSpec(time_contract_version=2, calendar="NYSE", timestamp_semantics="session_label", data_frequency="1d", bar_type="time")`. Do not supply intraday anchor/label/partial-bar fields. Date-valued and midnight datetime @@ -506,6 +513,32 @@ As with existing count horizons, a deadline beyond the available dataset is cens at the last available bar. `label_bars` reports the actual observed holding length and `label_time` the actual exit, not an inferred future observation. These changes target the next 0.2 development release, not the historical 0.1.7 patch. + + +```python +from datetime import date +from io import BytesIO + +import polars as pl +from ml4t.specs import FeedSpec +from ml4t.engineer.config import LabelingConfig +from ml4t.engineer.labeling import calendar_aware_labels + +prices = pl.DataFrame({ + "timestamp": [date(2025, 7, day) for day in (3, 7, 8)], + "close": [100.0, 101.0, 103.0], +}) +config = LabelingConfig.triple_barrier( + upper_barrier=None, lower_barrier=None, max_holding_period=1, window_unit="session", + feed_spec=FeedSpec(time_contract_version=2, calendar="NYSE", data_frequency="1d", + timestamp_semantics="session_label", bar_type="time"), +) +labels = calendar_aware_labels(prices, config) +assert labels["label_time"].to_list() == [date(2025, 7, 7), date(2025, 7, 8), date(2025, 7, 8)] +assert labels["label_bars"].to_list() == [1, 1, 0] +labels.write_parquet(BytesIO()) +labels.head(0).write_parquet(BytesIO()) +``` Their legacy `Date` inputs retain date-valued label times and calendar-day differences in `label_duration`; those dates do not become authoritative session identifiers or intraday instants. Shared-feed session labels remain explicitly rejected by this wrapper. From 9dcc1f6ca2161fb0337c5f25c45b89df0f30f478 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 18:51:05 -0400 Subject: [PATCH 22/28] test(labeling): qualify session horizons across DST and overnight venues (#94) --- tests/test_labeling_session_windows.py | 81 ++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/test_labeling_session_windows.py b/tests/test_labeling_session_windows.py index 90cf153..50e58d6 100644 --- a/tests/test_labeling_session_windows.py +++ b/tests/test_labeling_session_windows.py @@ -261,3 +261,84 @@ def test_barless_short_session_never_pushes_a_session_deadline_into_a_later_sess index - entry if index is not None else None for entry, index in enumerate(exits) ] assert result["label_return"][0] == pytest.approx(first_exit / 100) + + +@pytest.mark.parametrize("representation", ["UTC", "America/New_York", "naive"]) +def test_session_horizons_retain_actual_exchange_close_through_spring_dst(representation): + from ml4t.engineer.core.bar_grid import _expected_bar_labels + from ml4t.engineer.labeling.calendar import PandasMarketCalendar + + timestamps = list( + _expected_bar_labels( + PandasMarketCalendar("NYSE"), + datetime(2025, 3, 7, tzinfo=UTC), + datetime(2025, 3, 12, tzinfo=UTC), + timedelta(hours=1), + "right", + True, + ) + ) + series = pl.Series("timestamp", timestamps) + if representation != "UTC": + series = series.dt.convert_time_zone("America/New_York") + if representation == "naive": + series = series.dt.replace_time_zone(None) + prices = pl.DataFrame( + {"timestamp": series, "close": [100.0 + i for i in range(len(timestamps))]} + ) + feed = FeedSpec( + time_contract_version=2, + calendar="NYSE", + timezone="America/New_York", + timestamp_semantics="bar_close", + data_frequency="1h", + bar_type="time", + bar_grid_anchor="session_open", + bar_label="right", + partial_bar_policy="include", + ) + result = calendar_aware_labels(prices, _config(), feed_spec=feed) + expected = datetime(2025, 3, 10, 20, tzinfo=UTC) + actual = result["label_time"] + if representation == "naive": + actual = actual.dt.replace_time_zone("America/New_York") + assert actual.dt.convert_time_zone("UTC")[0] == expected + assert result["label_bars"][0] == 13 + assert result["label_return"][0] == pytest.approx(0.13) + assert result.select(prices.columns).equals(prices) + + +def test_overnight_cme_session_horizon_counts_exchange_labels_not_utc_civil_dates(): + from ml4t.engineer.core.bar_grid import _expected_bar_labels + from ml4t.engineer.labeling.calendar import PandasMarketCalendar + + timestamps = list( + _expected_bar_labels( + PandasMarketCalendar("CME_Equity"), + datetime(2025, 7, 6, tzinfo=UTC), + datetime(2025, 7, 10, tzinfo=UTC), + timedelta(hours=1), + "right", + True, + ) + ) + assert timestamps[0] == datetime(2025, 7, 6, 23, tzinfo=UTC) + prices = pl.DataFrame( + {"timestamp": timestamps, "close": [100.0 + i for i in range(len(timestamps))]} + ) + feed = FeedSpec( + time_contract_version=2, + calendar="CME_Equity", + timezone="UTC", + timestamp_semantics="bar_close", + data_frequency="1h", + bar_type="time", + bar_grid_anchor="session_open", + bar_label="right", + partial_bar_policy="include", + ) + result = calendar_aware_labels(prices, _config(), feed_spec=feed) + # Sunday evening belongs to Monday's exchange session. One following session ends Tuesday. + assert result["label_time"][0] == datetime(2025, 7, 8, 21, tzinfo=UTC) + assert result["label_bars"][0] == 45 + assert result["label_return"][0] == pytest.approx(0.45) From c8d603c8bd4b7a6ad51d5e7b7ed199c4252bc92d Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 18:52:41 -0400 Subject: [PATCH 23/28] test(api): retain scheduled sessions with no bars under exclusion policy (#76) --- tests/test_api_temporal_windows.py | 51 ++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_api_temporal_windows.py b/tests/test_api_temporal_windows.py index c0abc07..6cf2275 100644 --- a/tests/test_api_temporal_windows.py +++ b/tests/test_api_temporal_windows.py @@ -331,3 +331,54 @@ def test_empty_temporal_feature_input_preserves_typed_columns_and_no_observation assert result.height == 0 assert result.schema["sma"] == pl.Float64 assert result.select(prices.columns).equals(prices) + + +@pytest.mark.parametrize("side", ["left", "right"]) +@pytest.mark.parametrize("lazy", [False, True]) +def test_feature_session_windows_count_short_sessions_even_when_partial_policy_excludes_all_bars( + side, lazy +): + from ml4t.engineer.core.bar_grid import _expected_bar_labels + from ml4t.engineer.labeling.calendar import PandasMarketCalendar + + timestamps = list( + _expected_bar_labels( + PandasMarketCalendar("NYSE"), + datetime(2025, 7, 2, tzinfo=UTC), + datetime(2025, 7, 9, tzinfo=UTC), + timedelta(hours=5), + side, + False, + ) + ) + values = [100.0, 101.0, 102.0] + prices = pl.DataFrame( + { + "timestamp": timestamps, + "open": values, + "high": values, + "low": values, + "close": values, + "volume": 1.0, + } + ) + feed = FeedSpec( + time_contract_version=2, + calendar="NYSE", + timezone="UTC", + data_frequency="5h", + timestamp_semantics="bar_close" if side == "right" else "event_time", + bar_type="time", + bar_grid_anchor="session_open", + bar_label=side, + partial_bar_policy="exclude", + ) + result = compute_features( + prices.lazy() if lazy else prices, + [{"name": "sma", "params": {"period": 2}}], + feed_spec=feed, + window_unit="session", + ) + result = result.collect() if lazy else result + # July3 counts, despite having no5h bar. July7 must not include the July2 price. + assert result["sma"].to_list() == [None, 101.0, 101.5] From 2259bae6795ac367ddf3a00e44c6e83dccff42e3 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 19:19:34 -0400 Subject: [PATCH 24/28] fix(labeling): keep timestamp-free legacy durations portable and unset (#94) --- src/ml4t/engineer/labeling/triple_barrier.py | 11 ++++-- tests/test_labeling_empty_schema.py | 36 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/ml4t/engineer/labeling/triple_barrier.py b/src/ml4t/engineer/labeling/triple_barrier.py index 18fc71e..e0e1360 100644 --- a/src/ml4t/engineer/labeling/triple_barrier.py +++ b/src/ml4t/engineer/labeling/triple_barrier.py @@ -297,6 +297,11 @@ def _build_labeling_result( ) -> pl.DataFrame: """Build the result DataFrame from labeling outputs.""" calc_uniq = uniqueness is not None + duration_series = ( + pl.Series(time_durations.tolist()) + if time_durations.dtype.kind == "O" + else pl.Series(time_durations) + ) if "event_time" in data.columns: label_dict: dict[str, Any] = { @@ -306,7 +311,7 @@ def _build_labeling_result( "label_price": label_prices, "label_return": label_returns, "label_bars": bar_durations, - "label_duration": time_durations, + "label_duration": duration_series, "barrier_hit": barrier_hit, } if calc_uniq: @@ -326,7 +331,7 @@ def _build_labeling_result( "label_price": pl.Series(label_prices, dtype=pl.Float64), "label_return": pl.Series(label_returns, dtype=pl.Float64), "label_bars": pl.Series(bar_durations, dtype=pl.Int64), - "label_duration": pl.Series(time_durations), + "label_duration": duration_series, "barrier_hit": pl.Series(barrier_hit, dtype=pl.String), } if calc_uniq: @@ -365,7 +370,7 @@ def _triple_barrier_labels_single_group( duration_dtype = ( pl.Duration(time_dtype.time_unit if isinstance(time_dtype, pl.Datetime) else "ms") if timestamp_col is not None - else pl.Object + else pl.Null ) result = data.with_columns( label=pl.lit(None, dtype=pl.Int32), diff --git a/tests/test_labeling_empty_schema.py b/tests/test_labeling_empty_schema.py index 8244e9c..673e05c 100644 --- a/tests/test_labeling_empty_schema.py +++ b/tests/test_labeling_empty_schema.py @@ -43,3 +43,39 @@ def test_filtering_all_prices_still_produces_a_portable_typed_label_table( result.write_parquet(buffer) buffer.seek(0) assert pl.read_parquet(buffer).schema == result.schema + + +@pytest.mark.parametrize("empty", [False, True]) +@pytest.mark.parametrize("grouped", [False, True]) +@pytest.mark.parametrize("events", ["dense", "sparse", "none"]) +def test_timestamp_free_legacy_labels_keep_unknown_duration_portable_without_changing_holding_bars( + empty, grouped, events +): + prices = pl.DataFrame({"close": [100.0, 101.0, 102.0], "symbol": "A"}) + if not grouped: + prices = prices.drop("symbol") + if events != "dense": + prices = prices.with_row_index("event_time").with_columns( + pl.when(pl.col("event_time") % 2 == 0 if events == "sparse" else pl.col("close") > 1000) + .then(pl.col("event_time")) + .otherwise(None) + .alias("event_time") + ) + if empty: + prices = prices.filter(pl.col("close") > 1000) + config = LabelingConfig.triple_barrier( + upper_barrier=None, lower_barrier=None, max_holding_period=1 + ) + result = triple_barrier_labels(prices, config) + assert result.select(prices.columns).equals(prices) + assert result.schema["label_duration"] == pl.Null + assert result["label_duration"].is_null().all() + if not empty: + expected = {"dense": [1, 1, 0], "sparse": [1, None, 0], "none": [None] * 3} + assert result["label_bars"].to_list() == expected[events] + if events != "none": + assert result["label_return"][0] == pytest.approx(0.01) + buffer = BytesIO() + result.write_parquet(buffer) + buffer.seek(0) + assert pl.read_parquet(buffer).equals(result) From de4ab2a64a079fb24c82e108584f0b19d74d59c3 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 19:38:14 -0400 Subject: [PATCH 25/28] docs: remove superseded daily session-label rejection (#94) --- docs/user-guide/labeling.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/user-guide/labeling.md b/docs/user-guide/labeling.md index 6dc9b13..69780c4 100644 --- a/docs/user-guide/labeling.md +++ b/docs/user-guide/labeling.md @@ -487,6 +487,9 @@ definitions and chronological sorting; they do not acquire calendar interpretati timestamps. Use this wrapper when selecting the shared calendar/row contract. Standalone kernels reject configs declaring calendar/feed/window semantics instead of silently ignoring those declarations. +Standalone kernels retain date-valued label times and calendar-day differences in +`label_duration` for legacy `Date` inputs. These standalone calls do not validate +the dates against an exchange schedule. For `window_unit="session"`, an integer `max_holding_period=N` expires at the final bar of the Nth scheduled session after the entry session. A positive integral count @@ -539,10 +542,6 @@ assert labels["label_bars"].to_list() == [1, 1, 0] labels.write_parquet(BytesIO()) labels.head(0).write_parquet(BytesIO()) ``` -Their legacy `Date` inputs retain date-valued label times and calendar-day differences -in `label_duration`; those dates do not become authoritative session identifiers or -intraday instants. Shared-feed session labels remain explicitly rejected by this wrapper. - ## Sample Weighting AFML Chapter 4 addresses the problem of overlapping labels creating correlated samples. The library provides the full toolkit: From 98fe25ba49ac20c87518e03ae9cc92fa6169c228 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 20:19:44 -0400 Subject: [PATCH 26/28] docs: apply maintainer-directed batching to temporal contract maintenance --- AGENTS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index cf2b874..236272e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,3 +61,23 @@ uv build uv run python -c "import ml4t.engineer" uv run mkdocs build --strict ``` + +## Maintainer-directed change batching + +For our own fixes and agent work directed by the maintainer, use a bounded, coherent PR rather +than one PR per small defect or implementation step. One PR may close several related issues +when the changes serve the same user workflow, subsystem repair or compatibility objective and +can be reviewed and reverted together. Keep implementation, regressions, documentation and +follow-up repairs together; use focused commits and retain evidence for each defect. Do not +create separate issues or PRs merely for individual files, tests, assertions or platform variants. + +Split unrelated changes, independently risky or breaking changes, separate ownership boundaries, +and work that needs independent release or rollback decisions. A general maintenance label alone +does not make a batch coherent. Reuse or safely consolidate existing owned submissions instead of +opening overlapping PRs; preserve contributor attribution, recoverable history and other owners' +active work. Do not rewrite or close someone else's work merely to reduce the queue count. + +Run focused checks while iterating, then qualify the complete final revision with every applicable +required gate. Group related work before expensive CI; do not weaken checks, bypass review or +claim old-head checks qualify changed code. This maintainer-directed batching rule takes precedence +over a literal one-issue-per-PR reading of contributor intake guidance. From 47917fda1aaa9cd40c6421f675f31a311193d1ce Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 21:33:46 -0400 Subject: [PATCH 27/28] fix(labeling): distinguish session identifiers from physical elapsed time --- docs/user-guide/labeling.md | 3 + src/ml4t/engineer/labeling/AGENTS.md | 3 + src/ml4t/engineer/labeling/calendar.py | 68 ++++++++-- tests/test_labeling_session_windows.py | 172 ++++++++++++++++++++++++- 4 files changed, 234 insertions(+), 12 deletions(-) diff --git a/docs/user-guide/labeling.md b/docs/user-guide/labeling.md index 69780c4..8d866c1 100644 --- a/docs/user-guide/labeling.md +++ b/docs/user-guide/labeling.md @@ -475,6 +475,9 @@ Pass `feed_spec=FeedSpec(...)` directly or through `LabelingConfig.feed_spec`. S Naive feed timestamps require an explicit timezone, and ambiguous/nonexistent local times fail. Right-labeled/bar-close observations at a break boundary end the preceding tradable interval. Input timestamp representations are retained in the output. +Instant horizons and `label_duration` use physical elapsed time, including across +daylight-saving transitions. Shared `session_label` identifiers do not identify instants; +their `label_time` retains the session identifier and `label_duration` is unset. Legacy calls without FeedSpec keep naive-UTC handling. Custom protocol objects and explicit `calendar="auto"` remain compatibility modes without shared feed metadata. diff --git a/src/ml4t/engineer/labeling/AGENTS.md b/src/ml4t/engineer/labeling/AGENTS.md index 24c47bc..94b2c09 100644 --- a/src/ml4t/engineer/labeling/AGENTS.md +++ b/src/ml4t/engineer/labeling/AGENTS.md @@ -83,6 +83,9 @@ Session-count horizons expire at the last available bar of the Nth following sch session, censored at observed coverage end. They must not be split at lunch or overnight. Daily session-label rows preserve date identifiers and can span whole-session rows; elapsed durations need instants and must reject session-label identifiers. +Shared session-label outputs leave physical label_duration unset. For instant inputs, +use the declared timezone for both elapsed-horizon targeting and duration arithmetic, +including DST, while preserving input timestamp and label-time representations. Right-labeled bars can end at a break start; event timestamps at that instant are off-session. Standalone numerical labeling functions retain their legacy chronological row/duration diff --git a/src/ml4t/engineer/labeling/calendar.py b/src/ml4t/engineer/labeling/calendar.py index 5960f8e..4cbea40 100644 --- a/src/ml4t/engineer/labeling/calendar.py +++ b/src/ml4t/engineer/labeling/calendar.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, Protocol import numpy as np +import numpy.typing as npt import polars as pl from ml4t.specs import UNSET_CALENDAR, CalendarUnset, FeedSpec, TimestampSemantics, WindowUnit @@ -372,12 +373,55 @@ def _calendar_session_ids( return sorted_data, session_ids +def _calendar_group_labels( + data: pl.DataFrame, + config: LabelingConfig, + price_col: str, + timestamp_col: str | None, + timezone: str | None = None, + *, + max_holding_bars: npt.NDArray[np.int64] | None = None, +) -> pl.DataFrame: + dtype = data.schema[timestamp_col] if timestamp_col is not None else None + local_clock = ( + isinstance(dtype, pl.Datetime) and dtype.time_zone is None and timezone is not None + ) + if local_clock: + assert timestamp_col is not None + data = data.with_columns( + pl.col(timestamp_col) + .dt.replace_time_zone(timezone, ambiguous="raise", non_existent="raise") + .dt.convert_time_zone("UTC") + ) + result = _triple_barrier_labels_single_group( + data, + config, + price_col, + None, + None, + None, + timestamp_col, + False, + "returns_uniqueness", + max_holding_bars=max_holding_bars, + ) + if local_clock: + assert timestamp_col is not None + result = result.with_columns( + pl.col(timestamp_col, "label_time") + .dt.convert_time_zone(timezone) + .dt.replace_time_zone(None) + ) + return result + + def _segment_labels( data: pl.DataFrame, config: LabelingConfig, price_col: str, group_cols: list[str], timestamp_col: str | None = None, + timezone: str | None = None, ) -> pl.DataFrame: validate_price_no_nans(data, price_col) row_col = _temporary_column_name(data.columns, "__ml4t_observation_row") @@ -385,9 +429,7 @@ def _segment_labels( frames = indexed.partition_by(group_cols, maintain_order=True) if group_cols else [indexed] frames = frames or [indexed] results = [ - _triple_barrier_labels_single_group( - frame, config, price_col, None, None, None, timestamp_col, False, "returns_uniqueness" - ) + _calendar_group_labels(frame, config, price_col, timestamp_col, timezone) for frame in frames ] result = pl.concat(results, how="vertical").sort(row_col).drop(row_col) @@ -441,20 +483,21 @@ def _session_window_labels( exits = np.searchsorted(session_ids, session_ids + bounded_counts, side="right") - 1 periods = np.asarray(exits - np.arange(frame.height), dtype=np.int64) results.append( - _triple_barrier_labels_single_group( + _calendar_group_labels( frame, config, price_col, - None, - None, - None, timestamp_col, - False, - "returns_uniqueness", + None + if feed.timestamp_semantics == TimestampSemantics.SESSION_LABEL + else feed.timezone, max_holding_bars=periods, ) ) - return pl.concat(results, how="vertical") + result = pl.concat(results, how="vertical") + if feed.timestamp_semantics == TimestampSemantics.SESSION_LABEL: + result = result.with_columns(pl.lit(None).alias("label_duration")) + return result def calendar_aware_labels( @@ -547,6 +590,8 @@ def calendar_aware_labels( - Rows outside an explicit calendar's trading times are rejected - Without FeedSpec, legacy naive calendar timestamps are interpreted as UTC - FeedSpec naive timestamps use its declared timezone; ambiguous times fail + - Instant horizons and label_duration use physical elapsed time across DST; + session-label identifiers retain label_time but leave label_duration unset - Explicit None preserves per-asset observation order and global row order; label_time contains per-asset row offsets and label_duration is unset - A named 24/7 schedule still segments daily sessions; it is not row mode @@ -662,7 +707,7 @@ def calendar_aware_labels( resolved_price_col, resolved_group_cols, resolved_ts_col, - ) + ).with_columns(pl.lit(None).alias("label_duration")) # Create calendar instance if string provided if isinstance(selected, str): @@ -726,6 +771,7 @@ def calendar_aware_labels( resolved_price_col, session_group_cols, resolved_ts_col, + timezone=feed.timezone, ) return labeled.drop(session_col) diff --git a/tests/test_labeling_session_windows.py b/tests/test_labeling_session_windows.py index 50e58d6..949e9ec 100644 --- a/tests/test_labeling_session_windows.py +++ b/tests/test_labeling_session_windows.py @@ -7,7 +7,7 @@ from ml4t.specs import FeedSpec from ml4t.engineer.config import LabelingConfig -from ml4t.engineer.labeling import calendar_aware_labels +from ml4t.engineer.labeling import calendar_aware_labels, triple_barrier_labels def _feed(**kwargs): @@ -66,6 +66,66 @@ def test_daily_count_horizon_crosses_valid_sessions_instead_of_zero_length_segme assert result["label_time"].to_list() == [timestamps[1], timestamps[2], timestamps[2]] +@pytest.mark.parametrize("representation", ["date", "naive", "UTC", "Asia/Tokyo"]) +@pytest.mark.parametrize("unit", ["observation", "expected_bar", "session"]) +@pytest.mark.parametrize("empty", [False, True]) +def test_session_identifiers_do_not_fabricate_physical_elapsed_durations( + representation, unit, empty +): + days = [date(2025, 7, day) for day in (3, 7)] + timestamps = days + if representation != "date": + zone = None if representation == "naive" else ZoneInfo(representation) + timestamps = [datetime.combine(day, time(0, 15), tzinfo=zone) for day in days] + prices = pl.DataFrame({"timestamp": timestamps, "close": [100.0, 101.0]}) + if empty: + prices = prices.filter(pl.col("close") > 1000) + result = calendar_aware_labels(prices, _config(window_unit=unit), feed_spec=_feed()) + assert result.select(prices.columns).equals(prices) + assert result["label_duration"].is_null().all() + assert result.schema["label_duration"] != pl.Object + if not empty: + assert result["label_time"].to_list() == [timestamps[1], timestamps[1]] + assert result["label_bars"].to_list() == [1, 0] + assert result["label_return"].to_list() == pytest.approx([0.01, 0.0]) + buffer = BytesIO() + result.write_parquet(buffer) + buffer.seek(0) + assert pl.read_parquet(buffer).equals(result) + + +@pytest.mark.parametrize("suffix", ["json", "yaml"]) +def test_serialized_session_identifiers_keep_elapsed_duration_unset(tmp_path, suffix): + days = [date(2025, 7, day) for day in (3, 7)] + first = pl.DataFrame( + {"timestamp": days, "close": [100.0, 101.0], "symbol": "A", "event_time": [days[0], None]} + ) + second = first.with_columns((pl.col("close") * 2).alias("close"), pl.lit("B").alias("symbol")) + prices = pl.concat([second, first]).sort("timestamp", "symbol") + config = _config(feed_spec=_feed(entity_col="symbol")) + path = tmp_path / f"labels.{suffix}" + getattr(config, f"to_{suffix}")(path) + restored = getattr(LabelingConfig, f"from_{suffix}")(path) + result = calendar_aware_labels(prices, restored) + assert result["label_duration"].is_null().all() + for symbol in ("A", "B"): + group = result.filter(pl.col("symbol") == symbol) + assert group["label_bars"].to_list() == [1, None] + assert group["label_return"].to_list() == pytest.approx([0.01, None]) + + +def test_standalone_date_labels_keep_legacy_calendar_day_differences(): + prices = pl.DataFrame( + {"timestamp": [date(2025, 7, day) for day in (3, 7)], "close": [100.0, 101.0]} + ) + config = LabelingConfig.triple_barrier( + upper_barrier=None, lower_barrier=None, max_holding_period=1 + ) + result = triple_barrier_labels(prices, config) + assert result["label_duration"][0] == timedelta(days=4) + assert result["label_return"][0] == pytest.approx(0.01) + + def _intraday(): from ml4t.engineer.core.bar_grid import _expected_bar_labels from ml4t.engineer.labeling.calendar import PandasMarketCalendar @@ -97,6 +157,115 @@ def _intraday(): return prices, feed +@pytest.mark.parametrize( + "unit,expected", + [ + ("observation", timedelta(hours=1)), + ("expected_bar", timedelta(hours=1)), + ("session", timedelta(hours=26, minutes=30)), + ], +) +def test_instant_labeling_keeps_real_elapsed_durations(unit, expected): + prices, feed = _intraday() + result = calendar_aware_labels(prices, _config(window_unit=unit), feed_spec=feed) + assert result["label_duration"][0] == expected + + +@pytest.mark.parametrize("representation", ["UTC", "America/New_York", "naive"]) +@pytest.mark.parametrize("unit", ["observation", "expected_bar", "elapsed", "session"]) +@pytest.mark.parametrize("sparse", [False, True]) +def test_declared_local_clocks_use_physical_elapsed_time_through_spring_dst( + representation, unit, sparse +): + instants = [datetime(2025, 3, 9, 5, tzinfo=UTC) + timedelta(hours=i) for i in range(6)] + timestamps = pl.Series("timestamp", instants) + if representation != "UTC": + timestamps = timestamps.dt.convert_time_zone("America/New_York") + if representation == "naive": + timestamps = timestamps.dt.replace_time_zone(None) + prices = pl.DataFrame({"timestamp": timestamps, "close": [100.0 + i for i in range(6)]}) + if sparse: + prices = prices.with_columns( + pl.when(pl.col("close") == 101).then(pl.col("timestamp")).alias("event_time") + ) + feed = FeedSpec( + time_contract_version=2, + calendar="24/7", + timezone="America/New_York", + timestamp_semantics="event_time", + data_frequency="1h", + bar_type="time", + bar_grid_anchor="session_open", + bar_label="left", + partial_bar_policy="include", + ) + config = _config("2h" if unit == "elapsed" else 1 if unit == "session" else 2, window_unit=unit) + result = calendar_aware_labels(prices, config, feed_spec=feed) + assert result.select(prices.columns).equals(prices) + assert result.schema["label_time"] == timestamps.dtype + exits = [5] * 6 if unit == "session" else [2, 3, 4, 5, 5, 5] + for entry, exit_index in enumerate(exits): + if sparse and entry != 1: + assert result["label_time"][entry] is None + assert result["label_duration"][entry] is None + continue + assert result["label_time"][entry] == timestamps[exit_index] + assert result["label_bars"][entry] == exit_index - entry + assert result["label_return"][entry] == pytest.approx( + (100 + exit_index) / (100 + entry) - 1 + ) + assert result["label_duration"][entry] == instants[exit_index] - instants[entry] + + +@pytest.mark.parametrize("suffix", ["json", "yaml"]) +@pytest.mark.parametrize("time_unit", ["ms", "us", "ns"]) +@pytest.mark.parametrize("empty", [False, True]) +def test_serialized_local_clock_labels_preserve_panel_columns_and_timestamp_precision( + tmp_path, suffix, time_unit, empty +): + instants = [datetime(2025, 3, 9, 5, tzinfo=UTC) + timedelta(hours=i) for i in range(6)] + timestamps = ( + pl.Series("ts", instants, dtype=pl.Datetime(time_unit, "UTC")) + .dt.convert_time_zone("America/New_York") + .dt.replace_time_zone(None) + ) + first = pl.DataFrame({"ts": timestamps, "price": [100.0 + i for i in range(6)], "asset": "A"}) + second = first.with_columns((pl.col("price") * 2).alias("price"), pl.lit("B").alias("asset")) + prices = pl.concat([first, second]).sort("ts", "asset") + if empty: + prices = prices.filter(pl.col("price") > 1000) + config = _config( + "2h", + window_unit="elapsed", + feed_spec=FeedSpec( + time_contract_version=2, + calendar="24/7", + timezone="America/New_York", + timestamp_semantics="event_time", + timestamp_col="ts", + price_col="price", + entity_col="asset", + ), + ) + path = tmp_path / f"labels.{suffix}" + getattr(config, f"to_{suffix}")(path) + restored = getattr(LabelingConfig, f"from_{suffix}")(path) + result = calendar_aware_labels(prices, restored) + assert result.select(prices.columns).equals(prices.sort("asset", "ts")) + assert result.schema["label_time"] == pl.Datetime(time_unit) + assert result.schema["label_duration"] == pl.Duration(time_unit) + if not empty: + for asset in ("A", "B"): + group = result.filter(pl.col("asset") == asset) + assert group["label_time"][1] == datetime(2025, 3, 9, 4) + assert group["label_duration"][0] == timedelta(hours=2) + assert group["label_return"][1] == pytest.approx(2 / 101) + buffer = BytesIO() + result.write_parquet(buffer) + buffer.seek(0) + assert pl.read_parquet(buffer).equals(result) + + def test_intraday_session_horizon_reaches_next_session_close_not_lunch_or_overnight_break(): prices, feed = _intraday() result = calendar_aware_labels(prices, _config(), feed_spec=feed) @@ -305,6 +474,7 @@ def test_session_horizons_retain_actual_exchange_close_through_spring_dst(repres assert actual.dt.convert_time_zone("UTC")[0] == expected assert result["label_bars"][0] == 13 assert result["label_return"][0] == pytest.approx(0.13) + assert result["label_duration"][0] == timedelta(hours=76, minutes=30) assert result.select(prices.columns).equals(prices) From c69845d84fa7ae6571581e756746ebe65d283e5e Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 21:39:17 -0400 Subject: [PATCH 28/28] docs(calendars): reconcile helper guidance with daily crypto sessions --- src/ml4t/engineer/core/calendars/AGENTS.md | 2 ++ src/ml4t/engineer/core/calendars/__init__.py | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ml4t/engineer/core/calendars/AGENTS.md b/src/ml4t/engineer/core/calendars/AGENTS.md index 5dc3ca9..39a283d 100644 --- a/src/ml4t/engineer/core/calendars/AGENTS.md +++ b/src/ml4t/engineer/core/calendars/AGENTS.md @@ -20,3 +20,5 @@ Calendar utilities for session-aware workflows. This does not imply that all labeling APIs already consume the shared temporal contract. - Calendar-free feature calculations use `compute_features(..., calendar=None)`. A named continuous calendar is not calendar-free execution. +- `CryptoCalendar` uses UTC daily sessions, not hourly observation points. Maintenance + reopens do not create extra sessions; keep membership and daily boundary methods distinct. diff --git a/src/ml4t/engineer/core/calendars/__init__.py b/src/ml4t/engineer/core/calendars/__init__.py index 7fb8941..54f0b77 100644 --- a/src/ml4t/engineer/core/calendars/__init__.py +++ b/src/ml4t/engineer/core/calendars/__init__.py @@ -1,10 +1,10 @@ """ Exchange schedule helpers for membership, boundaries and dataframe annotations. -These helpers do not implement session-count feature windows or cross-validation. -CryptoCalendar retains legacy hourly-point enumeration; it is not an authoritative -session-label or shared WindowUnit engine. Shared calendar/grid consumers use -labeling.calendar.PandasMarketCalendar and FeedSpec declarations. +These helpers do not implement feature windows or cross-validation. CryptoCalendar +enumerates UTC daily sessions; recurring maintenance does not create extra sessions. +Shared calendar/grid consumers use labeling.calendar.PandasMarketCalendar and +FeedSpec declarations. """ from .base import ExchangeCalendar