From 42aa185363696d5c9116957de67918c1c2ac4525 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:17:52 -0400 Subject: [PATCH 01/21] test: reproduce calendar disable and session boundary errors --- tests/test_session_calendar_contract.py | 137 ++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 tests/test_session_calendar_contract.py diff --git a/tests/test_session_calendar_contract.py b/tests/test_session_calendar_contract.py new file mode 100644 index 0000000..31e0e78 --- /dev/null +++ b/tests/test_session_calendar_contract.py @@ -0,0 +1,137 @@ +"""Real provider/session workflows preserve declared calendar and timestamp meaning.""" + +from datetime import date + +import polars as pl +import pytest +from polars.testing import assert_frame_equal + +from ml4t.data import DataManager +from ml4t.data.core.models import Metadata +from ml4t.data.providers.synthetic import SyntheticProvider +from ml4t.data.sessions import SessionAssigner, SessionCompleter +from ml4t.data.storage.config import StorageConfig +from ml4t.data.storage.hive import HiveStorage +from ml4t.specs import FeedSpec + + +def provider_frame(frequency="daily", *, continuous=False): + with SyntheticProvider(calendar_mode="continuous" if continuous else "equity") as provider: + return provider.fetch_ohlcv("SPY", "2025-07-07", "2025-07-07", frequency) + + +@pytest.fixture(params=[False, True]) +def manager(request, tmp_path): + storage = HiveStorage(StorageConfig(base_path=tmp_path)) if request.param else None + with DataManager(storage=storage) as instance: + yield instance + + +def test_facade_default_is_nyse_not_a_missing_argument_error(manager): + frame = provider_frame() + assigned = manager.assign_sessions(frame, bar_frequency="daily") + assert assigned["session_date"].to_list() == [date(2025, 7, 7)] + + +@pytest.mark.parametrize("method", ["assign_sessions", "complete_sessions"]) +def test_explicit_none_preserves_provider_rows_without_session_or_imputation(manager, method): + frame = provider_frame() + assert_frame_equal(getattr(manager, method)(frame, calendar=None), frame) + + +def test_continuous_midnight_event_belongs_to_the_new_session(): + frame = provider_frame("hourly", continuous=True).head(2) + assigned = SessionAssigner("24/7").assign_sessions(frame, bar_frequency="intraday") + assert assigned["session_date"].to_list() == [date(2025, 7, 7), date(2025, 7, 7)] + + +def test_no_fill_flag_does_not_expand_a_provider_gap_without_storage(manager): + frame = provider_frame("minute", continuous=True).filter( + (pl.col("timestamp").dt.hour() == 13) & pl.col("timestamp").dt.minute().is_in([30, 32]) + ) + assigned = manager.complete_sessions(frame, calendar="NYSE", fill_gaps=False) + assert assigned.height == 2 + assert "is_imputed" not in assigned.columns + assert_frame_equal(assigned.select(frame.columns), frame) + + +def test_contradictory_explicit_exchange_alias_fails_without_input_mutation(manager): + frame = provider_frame() + original = frame.clone() + with pytest.raises(ValueError, match="conflict"): + manager.assign_sessions(frame, exchange="NYSE", calendar="LSE") + assert_frame_equal(frame, original) + + +@pytest.mark.parametrize( + "semantics, expected", [("event_time", date(2025, 7, 7)), ("bar_close", date(2025, 7, 6))] +) +def test_explicit_timestamp_meaning_overrides_daily_and_midnight_heuristics(semantics, expected): + frame = provider_frame("hourly", continuous=True).head(1) + feed = FeedSpec(calendar="24/7", timestamp_semantics=semantics) + assigned = SessionAssigner(feed_spec=feed).assign_sessions(frame, bar_frequency="daily") + assert assigned["session_date"].to_list() == [expected] + + +@pytest.mark.parametrize("calendar, hour", [("NYSE", 20), ("HKEX", 4)]) +def test_close_labeled_provider_bar_includes_close_or_break_start(calendar, hour): + frame = provider_frame("hourly", continuous=True).filter( + pl.col("timestamp").dt.hour() == hour - 1 + ) + frame = frame.with_columns(pl.col("timestamp") + pl.duration(hours=1)) + feed = FeedSpec(calendar=calendar, timestamp_semantics="bar_close") + assigned = SessionAssigner(feed_spec=feed).assign_sessions(frame) + assert assigned["session_date"].to_list() == [date(2025, 7, 7)] + event = SessionAssigner( + feed_spec=FeedSpec(calendar=calendar, timestamp_semantics="event_time") + ).assign_sessions(frame) + assert event["session_date"].to_list() == [None] + + +@pytest.mark.parametrize( + "declaration, expected", + [ + ({}, "NYSE"), + ({"calendar": None}, "NYSE"), + ({"time_contract_version": 2}, "NYSE"), + ({"time_contract_version": 2, "calendar": None}, None), + ({"time_contract_version": 2, "calendar": "24/7"}, "24/7"), + ], +) +def test_data_metadata_json_preserves_calendar_declaration_presence(declaration, expected): + metadata = Metadata(provider="synthetic", symbol="SPY", asset_class="equities", **declaration) + restored = Metadata.model_validate_json(metadata.model_dump_json()) + assert FeedSpec.from_any(restored).resolve_calendar() == expected + assert FeedSpec.from_mapping(metadata.model_dump()).resolve_calendar() == expected + assert FeedSpec.from_any(restored.model_copy()).resolve_calendar() == expected + if declaration == {"time_contract_version": 2}: + assert "calendar" not in restored.model_dump() + + +def test_feed_disable_survives_metadata_and_public_facade(manager): + metadata = Metadata( + provider="synthetic", + symbol="SPY", + asset_class="equities", + time_contract_version=2, + calendar=None, + ) + restored = Metadata.model_validate_json(metadata.model_dump_json()) + frame = provider_frame() + assert_frame_equal(manager.assign_sessions(frame, feed_spec=restored), frame) + assigned = manager.assign_sessions( + frame, feed_spec=restored, calendar="NYSE", bar_frequency="daily" + ) + assert assigned["session_date"].to_list() == [date(2025, 7, 7)] + + +def test_calendar_name_alias_is_compatible_but_conflicts_are_not_silently_ignored(): + assert SessionAssigner(calendar_name="NYSE").calendar_name == "NYSE" + assert SessionCompleter(calendar_name="NYSE").calendar_name == "NYSE" + with pytest.raises(ValueError, match="conflict"): + SessionAssigner(calendar="NYSE", calendar_name="LSE") + + +def test_calendar_free_low_level_completion_is_not_a_continuous_grid(): + frame = provider_frame() + assert_frame_equal(SessionCompleter(calendar=None).complete_sessions(frame), frame) From 56f15621fa4ed41996d3f27e1c0c4ffc298a379e Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:23:57 -0400 Subject: [PATCH 02/21] fix: preserve calendar declarations and session boundary semantics --- docs/user-guide/sessions.md | 41 ++++++- src/ml4t/data/core/models.py | 62 ++++++++++- src/ml4t/data/data_manager.py | 44 +++----- src/ml4t/data/managers/metadata_manager.py | 31 ++---- src/ml4t/data/sessions/assigner.py | 118 +++++++++++++++++---- src/ml4t/data/sessions/completer.py | 61 +++++++---- tests/test_session_calendar_contract.py | 75 +++++++++++++ 7 files changed, 330 insertions(+), 102 deletions(-) diff --git a/docs/user-guide/sessions.md b/docs/user-guide/sessions.md index 84f2765..c3582c5 100644 --- a/docs/user-guide/sessions.md +++ b/docs/user-guide/sessions.md @@ -10,9 +10,9 @@ dependency is included with the package. ## Assign Session Dates -`SessionAssigner` requires a Polars `Datetime` column named `timestamp`. Naive timestamps are -interpreted as UTC; timezone-aware timestamps are converted to UTC before they are matched to a -schedule. +`SessionAssigner` accepts Polars `Datetime` instants or `Date` session labels in `timestamp`. +Naive instants use UTC unless the feed declares a timezone; timezone-aware instants are matched in +UTC. Ambiguous or nonexistent local times fail rather than being guessed. ```python from datetime import datetime @@ -55,6 +55,35 @@ fall within an open interval: Set `bar_frequency="daily"` when timestamps are daily labels. The default, `"auto"`, treats a series as daily when every non-null timestamp is midnight. +Explicit `FeedSpec.timestamp_semantics` overrides these heuristics: `session_label` preserves the +civil label date, `event_time` matches an instant, and `bar_close` matches the ending bar boundary. +Event intervals include the open and exclude the close; close-labeled bars exclude the open and +include the close. The corresponding boundaries apply to lunch breaks. An explicit `bar_label` +also selects the declared side; `event_time` alone does not infer a bar side. This assignment does +not validate intraday grid completeness. + +```python +from ml4t.specs import FeedSpec + +feed = FeedSpec(calendar="24/7", timestamp_semantics="event_time", time_contract_version=2) +assigned = SessionAssigner(feed_spec=feed).assign_sessions(bars) +unchanged = SessionAssigner(calendar=None).assign_sessions(bars) +``` + +Use the canonical `calendar` keyword on constructors and facade methods. Explicit selection wins +over feed metadata, then NYSE applies. Explicit `None` returns observations unchanged, without +adding a session column or reconstructing a continuous grid. `calendar="24/7"` remains a real +schedule, including an event exactly at midnight in the new day's session. Unknown names fail. +The positional calendar argument, `calendar_name` constructor keyword and explicit `exchange` +selector remain compatibility forms; contradictory explicit selectors raise an error. Descriptive +exchange identity in feed metadata does not select a calendar. + +Data's `Metadata` supports the same versioned temporal declaration. Version 1 retains legacy null +inheritance; version 2 preserves omitted calendar versus explicit null through JSON, copying and +shared FeedSpec projection. Use `model_dump(mode="json")` before a YAML writer, and ordinary +`model_dump_json()` for JSON; do not deliberately exclude null fields when preserving a disable +declaration. Declared timezone, timestamp semantics and fixed-grid fields survive these bridges. + ## Select an Exchange Calendar `SessionAssigner.from_exchange()` recognizes these exchange codes: @@ -103,6 +132,12 @@ Session completion rejects duplicate or non-minute-aligned timestamps and observ selected schedule. Use session assignment first when you need to inspect how individual timestamps map to the exchange calendar. +Completion remains a one-minute, open-labeled operation, not a general bar-grid validator. Explicit +close-labeled or session-label metadata is rejected. `calendar=None` returns the input unchanged. +`DataManager.complete_sessions(fill_gaps=False)` performs assignment only and never expands rows, +whether or not storage is configured. Feed declarations are accepted through the facade's +`feed_spec` keyword with the same precedence as the low-level classes. + ## Use Session Dates in Validation With scikit-learn installed, group observations by `session_date` when a split must not divide one diff --git a/src/ml4t/data/core/models.py b/src/ml4t/data/core/models.py index f2993ac..a892f69 100644 --- a/src/ml4t/data/core/models.py +++ b/src/ml4t/data/core/models.py @@ -7,9 +7,18 @@ from typing import Any import polars as pl -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_serializer, model_validator from ml4t.data.assets.asset_class import AssetClass # noqa: F401 — re-exported +from ml4t.specs import ( + UNSET_CALENDAR, + BarGridAnchor, + BarLabel, + CalendarUnset, + FeedSpec, + PartialBarPolicy, + TimestampSemantics, +) class SchemaVersion(StrEnum): @@ -73,7 +82,13 @@ class Metadata(BaseModel): # Exchange and calendar (for session-aware operations) exchange: str = "UNKNOWN" - calendar: str | None = None # pandas_market_calendars name (e.g., "CME_Globex_Crypto") + calendar: str | None | CalendarUnset = UNSET_CALENDAR + time_contract_version: int = 1 + timezone: str | None = None + timestamp_semantics: TimestampSemantics | str | None = None + bar_grid_anchor: BarGridAnchor | str | None = None + bar_label: BarLabel | str | None = None + partial_bar_policy: PartialBarPolicy | str | None = None # Tracking start_date: datetime | None = None @@ -89,6 +104,49 @@ class Metadata(BaseModel): model_config = {"use_enum_values": True} + @field_validator("calendar", mode="wrap") + @classmethod + def preserve_calendar_presence(cls, value: Any, handler: Any) -> Any: + return value if value is UNSET_CALENDAR else handler(value) + + @field_validator("time_contract_version", mode="before") + @classmethod + def validate_time_contract_version(cls, value: Any) -> int: + return FeedSpec(time_contract_version=value).time_contract_version + + def _time_declaration(self) -> FeedSpec: + return FeedSpec( + calendar=self.calendar, + time_contract_version=self.time_contract_version, + timezone=self.timezone, + timestamp_semantics=self.timestamp_semantics, + bar_grid_anchor=self.bar_grid_anchor, + bar_label=self.bar_label, + partial_bar_policy=self.partial_bar_policy, + ) + + @model_validator(mode="after") + def normalize_time_declaration(self) -> Metadata: + object.__setattr__(self, "calendar", self._time_declaration().calendar) + return self + + @model_serializer(mode="wrap") + def serialize_time_declaration(self, handler: Any) -> dict[str, Any]: + data = handler(self) + declaration = self._time_declaration().to_dict() + for name in ( + "calendar", + "time_contract_version", + "bar_grid_anchor", + "bar_label", + "partial_bar_policy", + ): + if name in declaration and name in data: + data[name] = declaration[name] + elif name not in declaration: + data.pop(name, None) + return data + @property def frequency(self) -> str: """Get frequency string for backward compatibility. diff --git a/src/ml4t/data/data_manager.py b/src/ml4t/data/data_manager.py index 0eca1e4..42cead4 100644 --- a/src/ml4t/data/data_manager.py +++ b/src/ml4t/data/data_manager.py @@ -29,6 +29,7 @@ from ml4t.data.managers.metadata_manager import MetadataManager from ml4t.data.managers.provider_manager import ProviderManager, ProviderRouter from ml4t.data.managers.storage_manager import StorageManager +from ml4t.specs import UNSET_CALENDAR, CalendarUnset logger = structlog.get_logger() @@ -453,51 +454,30 @@ def assign_sessions( self, df: pl.DataFrame, exchange: str | None = None, - calendar: str | None = None, + calendar: str | None | CalendarUnset = UNSET_CALENDAR, bar_frequency: Literal["auto", "daily", "intraday"] = "auto", + *, + feed_spec: Any | None = None, ) -> pl.DataFrame: """Assign sessions to daily period labels or intraday timestamps.""" - if not self._metadata_manager: - # Fall back to direct implementation - from ml4t.data.sessions import SessionAssigner - - if calendar is None: - if exchange is None: - raise ValueError("Must provide either exchange or calendar parameter") - assigner = SessionAssigner.from_exchange(exchange) - else: - assigner = SessionAssigner(calendar) - return assigner.assign_sessions(df, bar_frequency=bar_frequency) - - return self._metadata_manager.assign_sessions(df, exchange, calendar, bar_frequency) + manager = self._metadata_manager or MetadataManager(None) + return manager.assign_sessions(df, exchange, calendar, bar_frequency, feed_spec=feed_spec) def complete_sessions( self, df: pl.DataFrame, exchange: str | None = None, - calendar: str | None = None, + calendar: str | None | CalendarUnset = UNSET_CALENDAR, fill_gaps: bool = True, fill_method: str = "forward", zero_volume: bool = True, + *, + feed_spec: Any | None = None, ) -> pl.DataFrame: """Complete sessions by filling gaps.""" - if not self._metadata_manager: - # Fall back to direct implementation - from ml4t.data.sessions import SessionCompleter - from ml4t.data.sessions.assigner import EXCHANGE_CALENDARS - - if calendar is None: - if exchange is None: - raise ValueError("Must provide either exchange or calendar parameter") - calendar = EXCHANGE_CALENDARS.get(exchange.upper()) - if not calendar: - raise ValueError(f"Unknown exchange '{exchange}'") - - completer = SessionCompleter(calendar) - return completer.complete_sessions(df, fill_method=fill_method, zero_volume=zero_volume) - - return self._metadata_manager.complete_sessions( - df, exchange, calendar, fill_gaps, fill_method, zero_volume + manager = self._metadata_manager or MetadataManager(None) + return manager.complete_sessions( + df, exchange, calendar, fill_gaps, fill_method, zero_volume, feed_spec=feed_spec ) # ======================================================================== diff --git a/src/ml4t/data/managers/metadata_manager.py b/src/ml4t/data/managers/metadata_manager.py index 55e9db4..1605fd8 100644 --- a/src/ml4t/data/managers/metadata_manager.py +++ b/src/ml4t/data/managers/metadata_manager.py @@ -16,6 +16,7 @@ from ml4t.data.storage.backend import normalize_storage_metadata from ml4t.data.storage.keys import storage_key_path +from ml4t.specs import UNSET_CALENDAR, CalendarUnset logger = structlog.get_logger() @@ -156,8 +157,10 @@ def assign_sessions( self, df: pl.DataFrame, exchange: str | None = None, - calendar: str | None = None, + calendar: str | None | CalendarUnset = UNSET_CALENDAR, bar_frequency: Literal["auto", "daily", "intraday"] = "auto", + *, + feed_spec: Any | None = None, ) -> pl.DataFrame: """Assign session_date column to DataFrame based on exchange calendar. @@ -175,12 +178,7 @@ def assign_sessions( """ from ml4t.data.sessions import SessionAssigner - if calendar is None: - if exchange is None: - raise ValueError("Must provide either exchange or calendar parameter") - assigner = SessionAssigner.from_exchange(exchange) - else: - assigner = SessionAssigner(calendar) + assigner = SessionAssigner(calendar, exchange=exchange, feed_spec=feed_spec) return assigner.assign_sessions(df, bar_frequency=bar_frequency) @@ -188,10 +186,12 @@ def complete_sessions( self, df: pl.DataFrame, exchange: str | None = None, - calendar: str | None = None, + calendar: str | None | CalendarUnset = UNSET_CALENDAR, fill_gaps: bool = True, fill_method: str = "forward", zero_volume: bool = True, + *, + feed_spec: Any | None = None, ) -> pl.DataFrame: """Complete sessions by filling gaps. @@ -213,24 +213,13 @@ def complete_sessions( """ from ml4t.data.sessions import SessionCompleter - if calendar is None: - if exchange is None: - raise ValueError("Must provide either exchange or calendar parameter") - - from ml4t.data.sessions.assigner import EXCHANGE_CALENDARS - - calendar = EXCHANGE_CALENDARS.get(exchange.upper()) - if not calendar: - raise ValueError(f"Unknown exchange '{exchange}'") - - completer = SessionCompleter(calendar) - if not fill_gaps: from ml4t.data.sessions import SessionAssigner - assigner = SessionAssigner(calendar) + assigner = SessionAssigner(calendar, exchange=exchange, feed_spec=feed_spec) return assigner.assign_sessions(df) + completer = SessionCompleter(calendar, exchange=exchange, feed_spec=feed_spec) return completer.complete_sessions( df, fill_method=fill_method, diff --git a/src/ml4t/data/sessions/assigner.py b/src/ml4t/data/sessions/assigner.py index b940cf2..1c7c410 100644 --- a/src/ml4t/data/sessions/assigner.py +++ b/src/ml4t/data/sessions/assigner.py @@ -3,11 +3,13 @@ from __future__ import annotations from datetime import date, datetime -from typing import Literal +from typing import Any, Literal import polars as pl import structlog +from ml4t.specs import UNSET_CALENDAR, BarLabel, CalendarUnset, FeedSpec, TimestampSemantics + logger = structlog.get_logger() @@ -41,13 +43,37 @@ class SessionAssigner: The session_date is the date when the session ENDS (4pm date). """ - def __init__(self, calendar_name: str): + def __init__( + self, + calendar: str | None | CalendarUnset = UNSET_CALENDAR, + *, + calendar_name: str | None | CalendarUnset = UNSET_CALENDAR, + exchange: str | None = None, + feed_spec: Any | None = None, + ) -> None: """Initialize session assigner. Args: - calendar_name: Name of calendar from pandas_market_calendars - (e.g., "CME_Globex_Crypto", "NYSE", "NASDAQ") + calendar: Explicit name; None disables session handling. + calendar_name: Compatibility alias for calendar. + exchange: Compatibility selector, not inferred from descriptive metadata. + feed_spec: Shared calendar, timezone and timestamp declarations. """ + selectors = [calendar_name] + if exchange is not None: + if exchange.upper() not in EXCHANGE_CALENDARS: + raise ValueError(f"Unknown exchange '{exchange}'") + selectors.append(EXCHANGE_CALENDARS[exchange.upper()]) + for alias in selectors: + if alias is not UNSET_CALENDAR: + if calendar is not UNSET_CALENDAR and calendar != alias: + raise ValueError("conflicting calendar selectors") + calendar = alias + self.feed_spec = FeedSpec.from_any(feed_spec) + self.calendar_name = self.feed_spec.resolve_calendar(calendar=calendar) + self.calendar = None + if self.calendar_name is None: + return try: import pandas_market_calendars as mcal except ImportError: @@ -56,13 +82,12 @@ def __init__(self, calendar_name: str): "Install with: pip install pandas-market-calendars" ) - self.calendar_name = calendar_name try: - self.calendar = mcal.get_calendar(calendar_name) + self.calendar = mcal.get_calendar(self.calendar_name) except Exception as e: - raise ValueError(f"Unknown calendar '{calendar_name}': {e}") + raise ValueError(f"Unknown calendar '{self.calendar_name}': {e}") - logger.info(f"Initialized SessionAssigner with calendar: {calendar_name}") + logger.info(f"Initialized SessionAssigner with calendar: {self.calendar_name}") @classmethod def from_exchange(cls, exchange: str) -> SessionAssigner: @@ -109,6 +134,8 @@ def assign_sessions( Raises: ValueError: If timestamp column missing """ + if self.calendar is None: + return df if "timestamp" not in df.columns: raise ValueError("DataFrame must have 'timestamp' column") if outside_session not in {"null", "raise", "drop"}: @@ -121,14 +148,22 @@ def assign_sessions( return df.with_columns(pl.lit(None).cast(pl.Date).alias("session_date")) timestamp_dtype = df.schema["timestamp"] - if not isinstance(timestamp_dtype, pl.Datetime): - raise TypeError("'timestamp' must contain Datetime values") + if timestamp_dtype != pl.Date and not isinstance(timestamp_dtype, pl.Datetime): + raise TypeError("'timestamp' must contain Date or Datetime values") + semantics = self.feed_spec.timestamp_semantics + if timestamp_dtype == pl.Date and ( + semantics in (TimestampSemantics.EVENT_TIME, TimestampSemantics.BAR_CLOSE) + or self.feed_spec.bar_label is not None + ): + raise ValueError("event_time/bar_close timestamps must contain Datetime values") # Auto-detect date range from data if start_date is None: - start_date = _datetime_scalar(df["timestamp"].min(), "timestamp") + start_date = df["timestamp"].min() if end_date is None: - end_date = _datetime_scalar(df["timestamp"].max(), "timestamp") + end_date = df["timestamp"].max() + if start_date is None or end_date is None: + raise TypeError("'timestamp' must contain non-null Datetime or Date values") logger.info( f"Assigning sessions for {len(df)} rows", @@ -179,8 +214,12 @@ def assign_sessions( return result timestamp_expr = pl.col("timestamp") - if timestamp_dtype.time_zone is None: - timestamp_expr = timestamp_expr.dt.replace_time_zone("UTC") + if timestamp_dtype == pl.Date: + timestamp_expr = timestamp_expr.cast(pl.Datetime("us")) + if timestamp_dtype == pl.Date or timestamp_dtype.time_zone is None: + timestamp_expr = timestamp_expr.dt.replace_time_zone( + self.feed_spec.timezone or "UTC" + ) timestamp_expr = timestamp_expr.dt.convert_time_zone("UTC").cast( pl.Datetime("us", "UTC") ) @@ -193,10 +232,25 @@ def assign_sessions( if isinstance(value, datetime) ) daily_labels = bar_frequency == "daily" or (bar_frequency == "auto" and inferred_daily) + if semantics is not None: + daily_labels = semantics == TimestampSemantics.SESSION_LABEL + elif self.feed_spec.bar_label is not None: + daily_labels = False + elif timestamp_dtype == pl.Date: + daily_labels = True if daily_labels: + label_expr = pl.col("timestamp") + if ( + isinstance(timestamp_dtype, pl.Datetime) + and timestamp_dtype.time_zone is not None + and self.feed_spec.timezone is not None + ): + label_expr = label_expr.dt.convert_time_zone(self.feed_spec.timezone) + if isinstance(timestamp_dtype, pl.Datetime): + label_expr = label_expr.dt.date() result = ( df.with_row_index("_session_row_index") - .with_columns(timestamp_expr.dt.date().alias("_session_date_label")) + .with_columns(label_expr.alias("_session_date_label")) .join( session_df.select( pl.col("_assigned_session_date").alias("_calendar_session_date_label"), @@ -234,20 +288,38 @@ def assign_sessions( ) .sort("_session_join_timestamp") ) + close_labeled = ( + semantics == TimestampSemantics.BAR_CLOSE + or self.feed_spec.bar_label == BarLabel.RIGHT + ) + join_boundary = "_calendar_session_end" if close_labeled else "_calendar_session_start" joined = observations.join_asof( - session_df, + session_df.sort(join_boundary), left_on="_session_join_timestamp", - right_on="_calendar_session_end", - strategy="forward", - ) - in_session = (pl.col("_session_timestamp") >= pl.col("_calendar_session_start")) & ( - pl.col("_session_timestamp") < pl.col("_calendar_session_end") + right_on=join_boundary, + strategy="forward" if close_labeled else "backward", ) + if close_labeled: + in_session = (pl.col("_session_timestamp") > pl.col("_calendar_session_start")) & ( + pl.col("_session_timestamp") <= pl.col("_calendar_session_end") + ) + else: + in_session = (pl.col("_session_timestamp") >= pl.col("_calendar_session_start")) & ( + pl.col("_session_timestamp") < pl.col("_calendar_session_end") + ) if "_calendar_break_start" in joined.columns: in_break = ( pl.col("_calendar_break_start").is_not_null() - & (pl.col("_session_timestamp") >= pl.col("_calendar_break_start")) - & (pl.col("_session_timestamp") < pl.col("_calendar_break_end")) + & ( + (pl.col("_session_timestamp") > pl.col("_calendar_break_start")) + if close_labeled + else (pl.col("_session_timestamp") >= pl.col("_calendar_break_start")) + ) + & ( + (pl.col("_session_timestamp") <= pl.col("_calendar_break_end")) + if close_labeled + else (pl.col("_session_timestamp") < pl.col("_calendar_break_end")) + ) ) in_session &= ~in_break result = joined.with_columns( diff --git a/src/ml4t/data/sessions/completer.py b/src/ml4t/data/sessions/completer.py index 2b340f3..a947fd2 100644 --- a/src/ml4t/data/sessions/completer.py +++ b/src/ml4t/data/sessions/completer.py @@ -6,10 +6,14 @@ from __future__ import annotations from datetime import date, datetime, timedelta +from typing import Any import polars as pl import structlog +from ml4t.data.sessions.assigner import SessionAssigner +from ml4t.specs import UNSET_CALENDAR, BarLabel, CalendarUnset, TimestampSemantics + logger = structlog.get_logger() @@ -37,28 +41,28 @@ class SessionCompleter: ``` """ - def __init__(self, calendar_name: str): + def __init__( + self, + calendar: str | None | CalendarUnset = UNSET_CALENDAR, + *, + calendar_name: str | None | CalendarUnset = UNSET_CALENDAR, + exchange: str | None = None, + feed_spec: Any | None = None, + ) -> None: """Initialize with exchange calendar. Args: - calendar_name: Name of pandas_market_calendars calendar - (e.g., "CME_Globex_Crypto", "NYSE", "NASDAQ") + calendar: Explicit schedule; None disables completion. + calendar_name: Compatibility alias. + exchange: Compatibility selector. + feed_spec: Shared temporal declaration. """ - try: - import pandas_market_calendars as mcal - except ImportError: - raise ImportError( - "pandas_market_calendars is required for session completion. " - "Install with: pip install pandas-market-calendars" - ) - - self.calendar_name = calendar_name - try: - self.calendar = mcal.get_calendar(calendar_name) - except Exception as e: - raise ValueError(f"Unknown calendar '{calendar_name}': {e}") - - logger.info(f"Initialized SessionCompleter with calendar: {calendar_name}") + assigner = SessionAssigner( + calendar, calendar_name=calendar_name, exchange=exchange, feed_spec=feed_spec + ) + self.calendar_name = assigner.calendar_name + self.calendar = assigner.calendar + self.feed_spec = assigner.feed_spec def complete_sessions( self, @@ -85,6 +89,15 @@ def complete_sessions( not minute-aligned, multiple symbols share a timestamp, or an observation falls outside a scheduled session or the requested half-open range """ + if self.calendar is None: + return df + if self.feed_spec.timestamp_semantics not in ( + None, + TimestampSemantics.EVENT_TIME, + ) or self.feed_spec.bar_label not in (None, BarLabel.LEFT): + raise NotImplementedError( + "minute completion currently requires open-labeled event_time bars" + ) if "timestamp" not in df.columns: raise ValueError("DataFrame must have 'timestamp' column") if fill_method not in {"forward", "backward", "none"}: @@ -121,11 +134,15 @@ def complete_sessions( ): raise ValueError("start_date and end_date must not be NaT") if request_start.tzinfo is None: - request_start = request_start.tz_localize("UTC") + request_start = request_start.tz_localize( + self.feed_spec.timezone or "UTC" + ).tz_convert("UTC") else: request_start = request_start.tz_convert("UTC") if request_end.tzinfo is None: - request_end = request_end.tz_localize("UTC") + request_end = request_end.tz_localize(self.feed_spec.timezone or "UTC").tz_convert( + "UTC" + ) else: request_end = request_end.tz_convert("UTC") if request_start >= request_end: @@ -192,7 +209,9 @@ def complete_sessions( # Ensure input data has matching timestamp type timestamp_expr = pl.col("timestamp") if timestamp_dtype.time_zone is None: - timestamp_expr = timestamp_expr.dt.replace_time_zone("UTC") + timestamp_expr = timestamp_expr.dt.replace_time_zone( + self.feed_spec.timezone or "UTC" + ) timestamp_expr = timestamp_expr.dt.convert_time_zone("UTC").cast( pl.Datetime("ns", "UTC") ) diff --git a/tests/test_session_calendar_contract.py b/tests/test_session_calendar_contract.py index 31e0e78..b62d60d 100644 --- a/tests/test_session_calendar_contract.py +++ b/tests/test_session_calendar_contract.py @@ -135,3 +135,78 @@ def test_calendar_name_alias_is_compatible_but_conflicts_are_not_silently_ignore def test_calendar_free_low_level_completion_is_not_a_continuous_grid(): frame = provider_frame() assert_frame_equal(SessionCompleter(calendar=None).complete_sessions(frame), frame) + + +def test_declared_bar_side_is_not_inferred_from_event_time(): + frame = provider_frame("hourly", continuous=True).head(1) + feed = FeedSpec( + calendar="24/7", + time_contract_version=2, + timestamp_semantics="event_time", + bar_label="right", + ) + assert SessionAssigner(feed_spec=feed).assign_sessions(frame)["session_date"].to_list() == [ + date(2025, 7, 6) + ] + + +def test_persisted_data_metadata_retains_calendar_disable_and_bar_grid(tmp_path): + import yaml + + metadata = Metadata( + provider="synthetic", + symbol="SPY", + asset_class="equities", + calendar=None, + time_contract_version=2, + timezone="UTC", + timestamp_semantics="event_time", + bar_grid_anchor="session_open", + bar_label="right", + partial_bar_policy="include", + ) + restored = Metadata.model_validate( + yaml.safe_load(yaml.safe_dump(metadata.model_dump(mode="json"))) + ) + feed = FeedSpec.from_any(restored) + assert feed.resolve_calendar() is None + assert feed.bar_label == "right" + assert feed.bar_grid_anchor == "session_open" + assert feed.partial_bar_policy == "include" + frame = provider_frame("hourly", continuous=True).head(1) + storage = HiveStorage(StorageConfig(base_path=tmp_path)) + storage.write(frame, "equities/hourly/SPY", restored.model_dump()) + with DataManager(storage=storage) as manager: + declared = manager.get_metadata_for_key("equities/hourly/SPY") + assert_frame_equal(manager.assign_sessions(frame, feed_spec=declared), frame) + + +def test_explicit_session_labels_keep_civil_dates_in_non_utc_timezone(): + frame = provider_frame().with_columns( + pl.col("timestamp") + .dt.replace_time_zone(None) + .dt.truncate("1d") + .dt.replace_time_zone("Asia/Tokyo") + ) + feed = FeedSpec(calendar="NYSE", timestamp_semantics="session_label", timezone="Asia/Tokyo") + result = SessionAssigner(feed_spec=feed).assign_sessions(frame, bar_frequency="intraday") + assert result["session_date"].to_list() == [date(2025, 7, 7)] + + +def test_naive_event_time_uses_declared_timezone_and_rejects_ambiguous_clock(): + frame = provider_frame("hourly", continuous=True).filter(pl.col("timestamp").dt.hour() == 14) + local = frame.with_columns( + pl.col("timestamp").dt.convert_time_zone("America/New_York").dt.replace_time_zone(None) + ) + feed = FeedSpec(calendar="NYSE", timestamp_semantics="event_time", timezone="America/New_York") + assert SessionAssigner(feed_spec=feed).assign_sessions(local)["session_date"].to_list() == [ + date(2025, 7, 7) + ] + with SyntheticProvider(calendar_mode="continuous") as provider: + ambiguous = ( + provider.fetch_ohlcv("SPY", "2025-11-02", "2025-11-02", "hourly") + .filter(pl.col("timestamp").dt.hour() == 1) + .with_columns(pl.col("timestamp").dt.replace_time_zone(None)) + ) + with pytest.raises(pl.exceptions.ComputeError, match="ambiguous"): + SessionAssigner(feed_spec=feed).assign_sessions(ambiguous) From ef487c61fd7c18e27e04b8c7800a1fed8d12de6e Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:29:15 -0400 Subject: [PATCH 03/21] test: expose calendar disable loss during provider updates --- tests/test_session_calendar_contract.py | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_session_calendar_contract.py b/tests/test_session_calendar_contract.py index b62d60d..ba9e6cf 100644 --- a/tests/test_session_calendar_contract.py +++ b/tests/test_session_calendar_contract.py @@ -210,3 +210,39 @@ def test_naive_event_time_uses_declared_timezone_and_rejects_ambiguous_clock(): ) with pytest.raises(pl.exceptions.ComputeError, match="ambiguous"): SessionAssigner(feed_spec=feed).assign_sessions(ambiguous) + + +def test_real_provider_update_preserves_a_stored_explicit_calendar_disable(tmp_path): + frame = provider_frame() + storage = HiveStorage(StorageConfig(base_path=tmp_path)) + storage.write( + frame, + "equities/daily/SPY", + {"provider": "synthetic", "calendar": None, "time_contract_version": 2}, + ) + with DataManager(storage=storage) as manager: + assert ( + FeedSpec.from_mapping( + manager.get_metadata_for_key("equities/daily/SPY") + ).resolve_calendar() + is None + ) + manager.update("SPY", fill_gaps=False) + assert ( + FeedSpec.from_mapping( + manager.get_metadata_for_key("equities/daily/SPY") + ).resolve_calendar() + is None + ) + + +@pytest.mark.parametrize("invalid", [{"calendar": {"bad": True}}, {"time_contract_version": 3}]) +def test_real_provider_update_does_not_silently_drop_invalid_temporal_metadata(tmp_path, invalid): + frame = provider_frame() + storage = HiveStorage(StorageConfig(base_path=tmp_path)) + storage.write(frame, "equities/daily/SPY", {"provider": "synthetic", **invalid}) + before = storage.get_metadata("equities/daily/SPY") + with DataManager(storage=storage) as manager, pytest.raises(ValueError): + manager.update("SPY", fill_gaps=False) + assert storage.get_metadata("equities/daily/SPY") == before + assert_frame_equal(storage.read("equities/daily/SPY").collect(), frame) From 55cf992e730f2d172ced199bd3137555961fba91 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:29:43 -0400 Subject: [PATCH 04/21] fix: retain declared null calendar when updating stored metadata --- src/ml4t/data/managers/storage_manager.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/ml4t/data/managers/storage_manager.py b/src/ml4t/data/managers/storage_manager.py index 7eb9925..b53d579 100644 --- a/src/ml4t/data/managers/storage_manager.py +++ b/src/ml4t/data/managers/storage_manager.py @@ -622,7 +622,11 @@ def update( existing_model_values = { name: value for name in Metadata.model_fields - if (value := existing_metadata.get(name)) is not None + if name in existing_metadata + and ( + (value := existing_metadata[name]) is not None + or (name == "calendar" and existing_metadata.get("time_contract_version") == 2) + ) } resolved_provider = ( provider @@ -660,7 +664,18 @@ def update( for detail in error.errors() if (location := detail.get("loc")) and isinstance(location[0], str) } - removable_fields = invalid_fields - {"provider", "symbol", "asset_class"} + removable_fields = invalid_fields - { + "provider", + "symbol", + "asset_class", + "calendar", + "time_contract_version", + "timezone", + "timestamp_semantics", + "bar_grid_anchor", + "bar_label", + "partial_bar_policy", + } if not removable_fields: raise logger.warning( From fb4d3b04e9bf0f30d6fa32c39dbefe881827710d Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:31:56 -0400 Subject: [PATCH 05/21] test: isolate temporal update validation from provider routing --- tests/test_session_calendar_contract.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_session_calendar_contract.py b/tests/test_session_calendar_contract.py index ba9e6cf..79faa1c 100644 --- a/tests/test_session_calendar_contract.py +++ b/tests/test_session_calendar_contract.py @@ -227,7 +227,7 @@ def test_real_provider_update_preserves_a_stored_explicit_calendar_disable(tmp_p ).resolve_calendar() is None ) - manager.update("SPY", fill_gaps=False) + manager.update("SPY", fill_gaps=False, provider="synthetic") assert ( FeedSpec.from_mapping( manager.get_metadata_for_key("equities/daily/SPY") @@ -242,7 +242,10 @@ def test_real_provider_update_does_not_silently_drop_invalid_temporal_metadata(t storage = HiveStorage(StorageConfig(base_path=tmp_path)) storage.write(frame, "equities/daily/SPY", {"provider": "synthetic", **invalid}) before = storage.get_metadata("equities/daily/SPY") - with DataManager(storage=storage) as manager, pytest.raises(ValueError): - manager.update("SPY", fill_gaps=False) + with ( + DataManager(storage=storage) as manager, + pytest.raises(ValueError, match="calendar|time_contract_version"), + ): + manager.update("SPY", fill_gaps=False, provider="synthetic") assert storage.get_metadata("equities/daily/SPY") == before assert_frame_equal(storage.read("equities/daily/SPY").collect(), frame) From 6488ae7db956b75274ae8c396fc28329d1bd69a6 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:38:37 -0400 Subject: [PATCH 06/21] test: reproduce invalid timezone declarations falling back to UTC --- tests/test_session_calendar_contract.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_session_calendar_contract.py b/tests/test_session_calendar_contract.py index 79faa1c..3b87184 100644 --- a/tests/test_session_calendar_contract.py +++ b/tests/test_session_calendar_contract.py @@ -249,3 +249,18 @@ def test_real_provider_update_does_not_silently_drop_invalid_temporal_metadata(t manager.update("SPY", fill_gaps=False, provider="synthetic") assert storage.get_metadata("equities/daily/SPY") == before assert_frame_equal(storage.read("equities/daily/SPY").collect(), frame) + + +@pytest.mark.parametrize("timezone", ["", "Not/A_Zone"]) +@pytest.mark.parametrize("adapter", [SessionAssigner, SessionCompleter]) +def test_invalid_declared_timezone_does_not_silently_become_utc(timezone, adapter): + frame = provider_frame().with_columns(pl.col("timestamp").dt.replace_time_zone(None)) + feed = FeedSpec.from_mapping({"timezone": timezone, "timestamp_semantics": "event_time"}) + original = frame.clone() + with pytest.raises(ValueError, match="timezone"): + instance = adapter(feed_spec=feed) + if adapter is SessionAssigner: + instance.assign_sessions(frame) + else: + instance.complete_sessions(frame) + assert_frame_equal(frame, original) From 692ab4f1ae79ca9d5c5a82fd9fb167bde7a2e281 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:39:12 -0400 Subject: [PATCH 07/21] fix: reject invalid declared timezones before session processing --- src/ml4t/data/sessions/assigner.py | 8 ++++++++ tests/test_session_calendar_contract.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/ml4t/data/sessions/assigner.py b/src/ml4t/data/sessions/assigner.py index 1c7c410..b35cee7 100644 --- a/src/ml4t/data/sessions/assigner.py +++ b/src/ml4t/data/sessions/assigner.py @@ -74,6 +74,14 @@ def __init__( self.calendar = None if self.calendar_name is None: return + timezone = self.feed_spec.timezone + if timezone is not None: + if not isinstance(timezone, str) or not timezone.strip(): + raise ValueError("timezone must be a non-empty name or None") + try: + pl.Series([], dtype=pl.Datetime("us", "UTC")).dt.convert_time_zone(timezone) + except (pl.exceptions.PolarsError, TypeError) as error: + raise ValueError(f"Unknown timezone '{timezone}'") from error try: import pandas_market_calendars as mcal except ImportError: diff --git a/tests/test_session_calendar_contract.py b/tests/test_session_calendar_contract.py index 3b87184..58d4dfb 100644 --- a/tests/test_session_calendar_contract.py +++ b/tests/test_session_calendar_contract.py @@ -257,7 +257,7 @@ def test_invalid_declared_timezone_does_not_silently_become_utc(timezone, adapte frame = provider_frame().with_columns(pl.col("timestamp").dt.replace_time_zone(None)) feed = FeedSpec.from_mapping({"timezone": timezone, "timestamp_semantics": "event_time"}) original = frame.clone() - with pytest.raises(ValueError, match="timezone"): + with pytest.raises((ValueError, pl.exceptions.ComputeError), match="time.?zone"): instance = adapter(feed_spec=feed) if adapter is SessionAssigner: instance.assign_sessions(frame) From 8c5cfc692440c893ff68013ecc3e44f8cb4b8056 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:49:16 -0400 Subject: [PATCH 08/21] test: expose silently ignored session and completion declarations --- tests/test_session_calendar_contract.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_session_calendar_contract.py b/tests/test_session_calendar_contract.py index 58d4dfb..dab56df 100644 --- a/tests/test_session_calendar_contract.py +++ b/tests/test_session_calendar_contract.py @@ -264,3 +264,27 @@ def test_invalid_declared_timezone_does_not_silently_become_utc(timezone, adapte else: instance.complete_sessions(frame) assert_frame_equal(frame, original) + + +@pytest.mark.parametrize( + "declaration, message", + [({"bar_type": "volume"}, "time bars"), ({"data_frequency": "hourly"}, "minute")], +) +def test_minute_completion_rejects_a_declared_non_minute_or_event_count_feed(declaration, message): + frame = provider_frame("hourly", continuous=True).filter(pl.col("timestamp").dt.hour() == 14) + with pytest.raises((ValueError, NotImplementedError), match=message): + SessionCompleter(feed_spec=FeedSpec(**declaration)).complete_sessions(frame) + + +def test_session_start_override_is_preserved_but_not_silently_applied_as_native_schedule(): + metadata = Metadata( + provider="synthetic", + symbol="SPY", + asset_class="equities", + time_contract_version=2, + session_start_time="12:00", + ) + restored = Metadata.model_validate_json(metadata.model_dump_json()) + assert FeedSpec.from_any(restored).session_start_time == "12:00" + with pytest.raises(NotImplementedError, match="session_start_time"): + SessionAssigner(feed_spec=restored) From fdadf93d73db071509c1db5ffde4261f07c1062b Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:52:43 -0400 Subject: [PATCH 09/21] fix: preserve and validate explicit session operation declarations --- docs/user-guide/sessions.md | 5 +++++ src/ml4t/data/core/models.py | 2 ++ src/ml4t/data/sessions/assigner.py | 4 ++++ src/ml4t/data/sessions/completer.py | 6 ++++++ 4 files changed, 17 insertions(+) diff --git a/docs/user-guide/sessions.md b/docs/user-guide/sessions.md index c3582c5..df4d958 100644 --- a/docs/user-guide/sessions.md +++ b/docs/user-guide/sessions.md @@ -62,6 +62,10 @@ include the close. The corresponding boundaries apply to lunch breaks. An explic also selects the declared side; `event_time` alone does not infer a bar side. This assignment does not validate intraday grid completeness. +`session_start_time` is retained in metadata, but these schedule adapters reject a non-null override. +Register and select an authoritative calendar with the required intervals instead of silently +replacing a named exchange's session boundaries. + ```python from ml4t.specs import FeedSpec @@ -134,6 +138,7 @@ map to the exchange calendar. Completion remains a one-minute, open-labeled operation, not a general bar-grid validator. Explicit close-labeled or session-label metadata is rejected. `calendar=None` returns the input unchanged. +Declared event-count bar types and frequencies other than one minute are also rejected. `DataManager.complete_sessions(fill_gaps=False)` performs assignment only and never expands rows, whether or not storage is configured. Feed declarations are accepted through the facade's `feed_spec` keyword with the same precedence as the low-level classes. diff --git a/src/ml4t/data/core/models.py b/src/ml4t/data/core/models.py index a892f69..159f39f 100644 --- a/src/ml4t/data/core/models.py +++ b/src/ml4t/data/core/models.py @@ -86,6 +86,7 @@ class Metadata(BaseModel): time_contract_version: int = 1 timezone: str | None = None timestamp_semantics: TimestampSemantics | str | None = None + session_start_time: str | None = None bar_grid_anchor: BarGridAnchor | str | None = None bar_label: BarLabel | str | None = None partial_bar_policy: PartialBarPolicy | str | None = None @@ -120,6 +121,7 @@ def _time_declaration(self) -> FeedSpec: time_contract_version=self.time_contract_version, timezone=self.timezone, timestamp_semantics=self.timestamp_semantics, + session_start_time=self.session_start_time, bar_grid_anchor=self.bar_grid_anchor, bar_label=self.bar_label, partial_bar_policy=self.partial_bar_policy, diff --git a/src/ml4t/data/sessions/assigner.py b/src/ml4t/data/sessions/assigner.py index b35cee7..6d3eb5a 100644 --- a/src/ml4t/data/sessions/assigner.py +++ b/src/ml4t/data/sessions/assigner.py @@ -74,6 +74,10 @@ def __init__( self.calendar = None if self.calendar_name is None: return + if self.feed_spec.session_start_time is not None: + raise NotImplementedError( + "session_start_time overrides require a registered calendar with that schedule" + ) timezone = self.feed_spec.timezone if timezone is not None: if not isinstance(timezone, str) or not timezone.strip(): diff --git a/src/ml4t/data/sessions/completer.py b/src/ml4t/data/sessions/completer.py index a947fd2..ad69ba0 100644 --- a/src/ml4t/data/sessions/completer.py +++ b/src/ml4t/data/sessions/completer.py @@ -91,6 +91,12 @@ def complete_sessions( """ if self.calendar is None: return df + if self.feed_spec.bar_type not in (None, "time"): + raise NotImplementedError("minute completion requires fixed time bars") + if self.feed_spec.data_frequency is not None and str( + self.feed_spec.data_frequency + ).lower() not in {"minute", "1minute", "1min", "1m"}: + raise ValueError("minute completion requires a declared one-minute frequency") if self.feed_spec.timestamp_semantics not in ( None, TimestampSemantics.EVENT_TIME, From 1f979aa8ee216c62dc9092b7cc2b68209e0304c8 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 10:54:54 -0400 Subject: [PATCH 10/21] fix: reject unsupported explicit policies in native minute completion --- docs/user-guide/sessions.md | 2 ++ src/ml4t/data/sessions/completer.py | 7 +++++++ tests/test_session_calendar_contract.py | 9 +++++++++ 3 files changed, 18 insertions(+) diff --git a/docs/user-guide/sessions.md b/docs/user-guide/sessions.md index df4d958..c43f7f8 100644 --- a/docs/user-guide/sessions.md +++ b/docs/user-guide/sessions.md @@ -139,6 +139,8 @@ map to the exchange calendar. Completion remains a one-minute, open-labeled operation, not a general bar-grid validator. Explicit close-labeled or session-label metadata is rejected. `calendar=None` returns the input unchanged. Declared event-count bar types and frequencies other than one minute are also rejected. +General bar-grid anchor/partial-bar policies are preserved as metadata but explicitly rejected by +this native minute completer; they are not silently approximated by its legacy minute template. `DataManager.complete_sessions(fill_gaps=False)` performs assignment only and never expands rows, whether or not storage is configured. Feed declarations are accepted through the facade's `feed_spec` keyword with the same precedence as the low-level classes. diff --git a/src/ml4t/data/sessions/completer.py b/src/ml4t/data/sessions/completer.py index ad69ba0..fe4fb80 100644 --- a/src/ml4t/data/sessions/completer.py +++ b/src/ml4t/data/sessions/completer.py @@ -91,6 +91,13 @@ def complete_sessions( """ if self.calendar is None: return df + if ( + self.feed_spec.bar_grid_anchor is not None + or self.feed_spec.partial_bar_policy is not None + ): + raise NotImplementedError( + "native minute completion does not support explicit general bar-grid policies" + ) if self.feed_spec.bar_type not in (None, "time"): raise NotImplementedError("minute completion requires fixed time bars") if self.feed_spec.data_frequency is not None and str( diff --git a/tests/test_session_calendar_contract.py b/tests/test_session_calendar_contract.py index dab56df..ce644d4 100644 --- a/tests/test_session_calendar_contract.py +++ b/tests/test_session_calendar_contract.py @@ -288,3 +288,12 @@ def test_session_start_override_is_preserved_but_not_silently_applied_as_native_ assert FeedSpec.from_any(restored).session_start_time == "12:00" with pytest.raises(NotImplementedError, match="session_start_time"): SessionAssigner(feed_spec=restored) + + +def test_native_minute_completion_does_not_silently_ignore_general_grid_policies(): + frame = provider_frame("hourly", continuous=True).filter(pl.col("timestamp").dt.hour() == 14) + feed = FeedSpec( + time_contract_version=2, bar_grid_anchor="session_open", partial_bar_policy="exclude" + ) + with pytest.raises(NotImplementedError, match="bar-grid"): + SessionCompleter(feed_spec=feed).complete_sessions(frame) From 8902caf5b4814c0d2bf71033174e332c7af4951c Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 11:19:33 -0400 Subject: [PATCH 11/21] test: reproduce intraday grid omissions and schedule declaration gaps --- tests/test_intraday_schedule_gaps.py | 219 +++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 tests/test_intraday_schedule_gaps.py diff --git a/tests/test_intraday_schedule_gaps.py b/tests/test_intraday_schedule_gaps.py new file mode 100644 index 0000000..b357ced --- /dev/null +++ b/tests/test_intraday_schedule_gaps.py @@ -0,0 +1,219 @@ +"""Real provider outages must not be hidden by fixed US clocks or observed-session inference.""" + +from datetime import date, time +from zoneinfo import ZoneInfo + +import polars as pl +import pytest +from polars.testing import assert_frame_equal + +from ml4t.data.providers.synthetic import SyntheticProvider +from ml4t.data.utils.gaps import GapDetector +from ml4t.specs import FeedSpec + + +def provider_frame(start="2025-07-07", end="2025-07-07", frequency="minute"): + with SyntheticProvider(calendar_mode="continuous") as provider: + return provider.fetch_ohlcv("SPY", start, end, frequency) + + +def declaration(calendar="NYSE", frequency="minute", **overrides): + return FeedSpec( + time_contract_version=2, + calendar=calendar, + data_frequency=frequency, + timestamp_semantics=overrides.pop("timestamp_semantics", "event_time"), + bar_grid_anchor="session_open", + bar_label=overrides.pop("bar_label", "left"), + partial_bar_policy=overrides.pop("partial_bar_policy", "include"), + **overrides, + ) + + +def gaps(frame, feed, **kwargs): + return GapDetector().detect_gaps(frame, frequency=feed.data_frequency, feed_spec=feed, **kwargs) + + +def test_unqualified_summer_outage_cannot_silently_return_no_gap(): + frame = provider_frame().filter( + (pl.col("timestamp").dt.hour() == 13) & pl.col("timestamp").dt.minute().is_in([30, 32]) + ) + with pytest.raises(ValueError, match="declared.*grid"): + GapDetector().detect_gaps(frame, frequency="minute") + + +@pytest.mark.parametrize("day,hour,minute", [("2025-07-07", 13, 30), ("2025-01-07", 14, 30)]) +def test_declared_nyse_dst_and_winter_outage_each_count_one_bar(day, hour, minute): + frame = provider_frame(day, day).filter( + (pl.col("timestamp").dt.hour() == hour) + & pl.col("timestamp").dt.minute().is_in([minute, minute + 2]) + ) + original = frame.clone() + assert sum(g.missing_periods for g in gaps(frame, declaration())) == 1 + assert_frame_equal(frame, original) + + +def test_london_morning_outage_is_not_discarded_by_the_us_clock(): + frame = provider_frame(frequency="hourly").filter(pl.col("timestamp").dt.hour().is_in([8, 10])) + found = gaps(frame, declaration("LSE", "hourly")) + assert len(found) == 1 + assert found[0].missing_periods == 1 + + +def test_holiday_and_early_close_are_not_missing_expected_bars(): + frame = provider_frame("2025-07-03", "2025-07-07").filter( + ( + (pl.col("timestamp").dt.date() == date(2025, 7, 3)) + & (pl.col("timestamp").dt.hour() == 16) + & (pl.col("timestamp").dt.minute() == 59) + ) + | ( + (pl.col("timestamp").dt.date() == date(2025, 7, 7)) + & (pl.col("timestamp").dt.hour() == 13) + & (pl.col("timestamp").dt.minute() == 30) + ) + ) + assert gaps(frame, declaration()) == [] + + +def test_missing_shortened_session_counts_its_actual_210_minutes(): + frame = provider_frame("2025-07-02", "2025-07-07").filter( + ( + (pl.col("timestamp").dt.date() == date(2025, 7, 2)) + & (pl.col("timestamp").dt.hour() == 19) + & (pl.col("timestamp").dt.minute() == 59) + ) + | ( + (pl.col("timestamp").dt.date() == date(2025, 7, 7)) + & (pl.col("timestamp").dt.hour() == 13) + & (pl.col("timestamp").dt.minute() == 30) + ) + ) + assert sum(g.missing_periods for g in gaps(frame, declaration())) == 210 + + +def test_all_asset_missing_session_keeps_390_expected_minutes(): + frame = provider_frame("2025-07-15", "2025-07-17").filter( + ( + (pl.col("timestamp").dt.date() == date(2025, 7, 15)) + & (pl.col("timestamp").dt.hour() == 19) + & (pl.col("timestamp").dt.minute() == 59) + ) + | ( + (pl.col("timestamp").dt.date() == date(2025, 7, 17)) + & (pl.col("timestamp").dt.hour() == 13) + & (pl.col("timestamp").dt.minute() == 30) + ) + ) + panel = pl.concat([frame.with_columns(pl.lit(s).alias("symbol")) for s in ("SPY", "QQQ")]) + for asset in panel.partition_by("symbol"): + assert sum(g.missing_periods for g in gaps(asset, declaration())) == 390 + + +def test_hong_kong_break_does_not_count_as_missing_minutes(): + frame = provider_frame().filter( + ((pl.col("timestamp").dt.hour() == 3) & (pl.col("timestamp").dt.minute() == 59)) + | ((pl.col("timestamp").dt.hour() == 5) & (pl.col("timestamp").dt.minute() == 0)) + ) + assert gaps(frame, declaration("HKEX")) == [] + + +def test_cme_overnight_calendar_keeps_a_missing_midnight_minute(): + frame = provider_frame("2025-07-06", "2025-07-07").filter( + ( + (pl.col("timestamp").dt.date() == date(2025, 7, 6)) + & (pl.col("timestamp").dt.hour() == 23) + & (pl.col("timestamp").dt.minute() == 59) + ) + | ( + (pl.col("timestamp").dt.date() == date(2025, 7, 7)) + & (pl.col("timestamp").dt.hour() == 0) + & (pl.col("timestamp").dt.minute() == 1) + ) + ) + assert sum(g.missing_periods for g in gaps(frame, declaration("CME_Equity"))) == 1 + + +def test_continuous_weekend_is_a_real_grid_but_explicit_none_is_not(): + frame = provider_frame("2025-07-11", "2025-07-14", frequency="hourly").filter( + pl.col("timestamp").dt.hour().eq(0) + & pl.col("timestamp").dt.date().is_in([date(2025, 7, 11), date(2025, 7, 14)]) + ) + feed = declaration("24/7", "hourly") + assert sum(g.missing_periods for g in gaps(frame, feed)) == 71 + assert gaps(frame, feed, calendar=None) == [] + assert gaps(frame, declaration(None, "hourly")) == [] + + +def test_right_labeled_close_bar_is_valid_and_missing_predecessor_is_counted(): + frame = provider_frame().filter( + ((pl.col("timestamp").dt.hour() == 19) & (pl.col("timestamp").dt.minute() == 58)) + | ((pl.col("timestamp").dt.hour() == 20) & (pl.col("timestamp").dt.minute() == 0)) + ) + feed = declaration(timestamp_semantics="bar_close", bar_label="right") + assert sum(g.missing_periods for g in gaps(frame, feed)) == 1 + + +def test_naive_local_timestamps_require_and_use_the_declared_timezone(): + frame = ( + provider_frame() + .filter( + (pl.col("timestamp").dt.hour() == 13) & pl.col("timestamp").dt.minute().is_in([30, 32]) + ) + .with_columns( + pl.col("timestamp").dt.convert_time_zone("America/New_York").dt.replace_time_zone(None) + ) + ) + assert ( + sum(g.missing_periods for g in gaps(frame, declaration(timezone="America/New_York"))) == 1 + ) + with pytest.raises(ValueError, match="timezone"): + gaps(frame, declaration()) + + +@pytest.mark.parametrize( + "missing", + ["bar_grid_anchor", "bar_label", "partial_bar_policy", "timestamp_semantics", "data_frequency"], +) +def test_incomplete_grid_metadata_cannot_silently_choose_a_bar_definition(missing): + frame = provider_frame().head(2) + values = declaration("24/7").to_dict() + values.pop(missing) + with pytest.raises(ValueError, match="declared.*grid"): + GapDetector().detect_gaps(frame, frequency="minute", feed_spec=values) + + +def test_off_grid_provider_rows_fail_without_mutating_input(): + frame = provider_frame(frequency="hourly").filter(pl.col("timestamp").dt.hour().is_in([14, 15])) + original = frame.clone() + with pytest.raises(ValueError, match="off.grid"): + gaps(frame, declaration("NYSE", "hourly")) + assert_frame_equal(frame, original) + + +@pytest.mark.parametrize("policy,expected", [("include", 1), ("exclude", 0)]) +def test_registered_custom_break_grid_restarts_and_honors_partial_bar_policy(policy, expected): + from pandas_market_calendars.market_calendar import MarketCalendar + + class DataGridRegressionCalendar(MarketCalendar): + name = "DataGridRegression" + tz = ZoneInfo("UTC") + regular_market_times = { + "market_open": ((None, time(9)),), + "break_start": ((None, time(11)),), + "break_end": ((None, time(12)),), + "market_close": ((None, time(14, 30)),), + } + + frame = provider_frame("2025-07-07", "2025-07-08", frequency="hourly").filter( + ( + (pl.col("timestamp").dt.date() == date(2025, 7, 7)) + & (pl.col("timestamp").dt.hour() == 13) + ) + | ( + (pl.col("timestamp").dt.date() == date(2025, 7, 8)) + & (pl.col("timestamp").dt.hour() == 9) + ) + ) + feed = declaration("DataGridRegression", "hourly", partial_bar_policy=policy) + assert sum(g.missing_periods for g in gaps(frame, feed)) == expected From 5c092d1f9a23796c6d3c78f8a011f6f8817b7e0f Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 11:22:47 -0400 Subject: [PATCH 12/21] fix: classify intraday gaps from declared exchange bar intervals --- docs/user-guide/incremental-updates.md | 45 ++++++- src/ml4t/data/utils/gaps.py | 178 +++++++++++++++---------- tests/test_gaps.py | 16 ++- tests/test_intraday_schedule_gaps.py | 33 +++++ 4 files changed, 199 insertions(+), 73 deletions(-) diff --git a/docs/user-guide/incremental-updates.md b/docs/user-guide/incremental-updates.md index cf34391..b51f04f 100644 --- a/docs/user-guide/incremental-updates.md +++ b/docs/user-guide/incremental-updates.md @@ -83,10 +83,47 @@ schedule. This operation does not validate off-session observations or discover observed range. Explicit event-time/bar-close metadata is rejected rather than reinterpreted as session labels. -Named calendar/feed declarations for intraday detection are not supported yet; legacy intraday -calls retain their existing behavior. Unknown calendars and malformed daily timestamps fail without -modifying the input. `fill_gaps()` remains a separate elapsed-grid operation, not calendar-aware -imputation. +Unknown calendars and malformed daily timestamps fail without modifying the input. `fill_gaps()` +remains a separate elapsed-grid operation, not calendar-aware imputation. + +### Inspect Intraday Gaps Against a Declared Grid + +```python +feed = FeedSpec( + time_contract_version=2, + calendar="NYSE", + data_frequency="minute", + timestamp_semantics="event_time", + bar_grid_anchor="session_open", + bar_label="left", + partial_bar_policy="include", +) +gaps = GapDetector().detect_gaps(df, frequency="minute", feed_spec=feed) +``` + +Intraday classification requires the complete version-2 declaration above. `event_time` alone +does not choose a bar side. `bar_close` requires `bar_label="right"`. Naive timestamps also require +`timezone`; ambiguous/nonexistent local clocks fail rather than selecting a DST interpretation. +Supported fixed intervals use positive minute/hour counts, such as `5minute`, `15min` or `hourly`. +The requested and declared frequencies must agree. Event-count bars and session-label timestamps +cannot define an intraday grid. + +The grid starts at each authoritative tradable interval's open and restarts after lunch breaks. +`partial_bar_policy="include"` retains a shorter final bar; `"exclude"` omits it. Right labels name +the bar's end, including the actual shortened close. Holidays, early closes, DST, overnight sessions +and registered custom calendars use their selected schedule, not the observed rows. Off-grid +observations fail clearly instead of changing the definition to fit the input. + +Run classification separately per asset. Missing bars strictly between the first and last observed +timestamps are counted, including a completely missing session, even if every asset has that outage. +Returned intraday bounds are UTC instants; `missing_periods` counts expected bars, while `duration` +is elapsed time. Detection does not infer missing boundaries outside the observed range or fill rows. +Explicit `calendar=None` disables classification, while `"24/7"` still expects continuous bars. + +This replaces the old fixed-US-clock approximation. Unqualified legacy intraday calls now require +a complete feed declaration or explicit `calendar=None`; the detector does not invent an anchor, +label side or partial-bar policy. Daily classification is unchanged. The separate intraday/weekly +backfill range planner below still has its documented limitations. ### Plan Daily Backfill Acquisition diff --git a/src/ml4t/data/utils/gaps.py b/src/ml4t/data/utils/gaps.py index 2baf493..87ac122 100644 --- a/src/ml4t/data/utils/gaps.py +++ b/src/ml4t/data/utils/gaps.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from bisect import bisect_left, bisect_right from dataclasses import dataclass from datetime import UTC, date, datetime, timedelta @@ -11,7 +12,14 @@ import structlog from ml4t.data.sessions.assigner import SessionAssigner -from ml4t.specs import UNSET_CALENDAR, CalendarUnset, FeedSpec, TimestampSemantics +from ml4t.specs import ( + UNSET_CALENDAR, + BarLabel, + CalendarUnset, + FeedSpec, + PartialBarPolicy, + TimestampSemantics, +) logger = structlog.get_logger() @@ -85,7 +93,7 @@ def detect_gaps( frequency: Data frequency (minute, hourly, daily, etc.) timestamp_col: Name of timestamp column is_crypto: If True, expect 24/7 data; if False, market hours only - calendar: Daily schedule override; None disables schedule-based detection. + calendar: Schedule override; None disables schedule-based detection. feed_spec: Shared feed declaration, resolved before the NYSE default. Returns: @@ -108,6 +116,8 @@ def detect_gaps( return [] if frequency.lower() in {"daily", "1day"}: return self._detect_daily_gaps(df, timestamp_col, frequency, selected_calendar, feed) + if self._intraday_interval(frequency) is not None: + return self._detect_intraday_gaps(df, timestamp_col, frequency, selected_calendar, feed) if calendar is not UNSET_CALENDAR or feed_spec is not None: raise NotImplementedError( "calendar/feed declarations currently support daily gaps only" @@ -139,21 +149,6 @@ def detect_gaps( actual_seconds = actual_interval.total_seconds() expected_seconds = expected_interval.total_seconds() - # Check if this is a gap (considering market hours for non-crypto) - if ( - not is_crypto - and frequency - in [ - "minute", - "5minute", - "15minute", - "30minute", - "hourly", - ] - and self._spans_non_trading_hours(current, next_ts, frequency) - ): - continue - # Detect gap if interval exceeds expected + tolerance if actual_seconds > expected_seconds + tolerance_seconds: missing_periods = int(actual_seconds / expected_seconds) - 1 @@ -177,6 +172,101 @@ def detect_gaps( return gaps + @staticmethod + def _intraday_interval(frequency: str) -> timedelta | None: + value = frequency.lower().strip().replace("hourly", "hour") + match = re.fullmatch(r"(\d+)?(minutes?|min|m|hours?|h)", value) + if match is None: + return None + count = int(match[1] or 1) + if count <= 0: + raise ValueError("bar frequency must be positive") + return timedelta(hours=count) if match[2].startswith("h") else timedelta(minutes=count) + + def _detect_intraday_gaps( + self, + df: pl.DataFrame, + timestamp_col: str, + frequency: str, + calendar: str, + feed: FeedSpec, + ) -> list[DataGap]: + interval = self._intraday_interval(frequency) + required = ( + "data_frequency", + "timestamp_semantics", + "bar_grid_anchor", + "bar_label", + "partial_bar_policy", + ) + if feed.time_contract_version != 2 or any(getattr(feed, name) is None for name in required): + raise ValueError("intraday detection requires a complete declared version-2 bar grid") + if feed.timestamp_semantics == TimestampSemantics.SESSION_LABEL: + raise ValueError("intraday detection requires a declared event/close bar grid") + if feed.bar_type not in (None, "time"): + raise ValueError("intraday grid detection requires fixed time bars") + if interval != self._intraday_interval(str(feed.data_frequency)): + raise ValueError("declared grid data_frequency must match the requested frequency") + assigner = SessionAssigner(calendar, feed_spec=feed) + if timestamp_col not in df.columns: + raise ValueError(f"DataFrame must have '{timestamp_col}' column") + dtype = df.schema[timestamp_col] + if not isinstance(dtype, pl.Datetime): + raise TypeError(f"'{timestamp_col}' must contain Datetime values for an intraday grid") + if df[timestamp_col].null_count(): + raise ValueError(f"'{timestamp_col}' must not contain null timestamps") + if dtype.time_zone is None and feed.timezone is None: + raise ValueError("naive intraday timestamps require a declared timezone") + if df.is_empty(): + return [] + expr = pl.col(timestamp_col) + if dtype.time_zone is None: + expr = expr.dt.replace_time_zone(feed.timezone) + observed = df.select(expr.dt.convert_time_zone("UTC")).to_series().unique().sort().to_list() + start, end = observed[0], observed[-1] + schedule = assigner.calendar.schedule( + start_date=(start - timedelta(days=2)).date(), + end_date=(end + timedelta(days=2)).date(), + ) + import pandas as pd + + grids = [] + for _, row in schedule.iterrows(): + opening = row["market_open"].to_pydatetime().astimezone(UTC) + closing = row["market_close"].to_pydatetime().astimezone(UTC) + intervals = [(opening, closing)] + if "break_start" in schedule.columns and not pd.isna(row["break_start"]): + intervals = [ + (opening, row["break_start"].to_pydatetime().astimezone(UTC)), + (row["break_end"].to_pydatetime().astimezone(UTC), closing), + ] + for opening, closing in intervals: + count, remainder = divmod(closing - opening, interval) + if remainder and feed.partial_bar_policy == PartialBarPolicy.INCLUDE: + count += 1 + if count <= 0: + continue + labels = pl.datetime_range( + opening, + opening + count * interval, + interval=interval, + closed="left", + eager=True, + ) + if feed.bar_label == BarLabel.RIGHT: + labels = (labels + interval).clip(upper_bound=closing) + grids.append(labels) + expected = pl.concat(grids).unique().sort().to_list() if grids else [] + expected_set = set(expected) + if any(value not in expected_set for value in observed): + raise ValueError("intraday observations contain off-grid timestamps") + gaps = [] + for current, following in zip(observed, observed[1:]): + missing = bisect_left(expected, following) - bisect_right(expected, current) + if missing > 0: + gaps.append(DataGap(current, following, missing, following - current, frequency)) + return gaps + def _detect_daily_gaps( self, df: pl.DataFrame, @@ -233,22 +323,11 @@ def _get_expected_interval(self, frequency: str, is_crypto: bool = False) -> tim Expected timedelta between consecutive points """ freq_lower = frequency.lower() + interval = self._intraday_interval(frequency) + if interval is not None: + return interval - # Handle minute-based frequencies - if "minute" in freq_lower: - if freq_lower == "minute" or freq_lower == "1minute": - return timedelta(minutes=1) - if freq_lower == "5minute": - return timedelta(minutes=5) - if freq_lower == "15minute": - return timedelta(minutes=15) - if freq_lower == "30minute": - return timedelta(minutes=30) - - # Handle other frequencies - elif freq_lower == "hourly" or freq_lower == "1hour": - return timedelta(hours=1) - elif freq_lower == "daily" or freq_lower == "1day": + if freq_lower == "daily" or freq_lower == "1day": if is_crypto: return timedelta(days=1) # For stocks, next trading day could be 1-3 days away @@ -261,41 +340,6 @@ def _get_expected_interval(self, frequency: str, is_crypto: bool = False) -> tim # Default to daily return timedelta(days=1) - def _spans_non_trading_hours( - self, - start: datetime, - end: datetime, - frequency: str, # noqa: ARG002 - ) -> bool: - """ - Check if gap spans non-trading hours (nights/weekends). - - Args: - start: Start of gap - end: End of gap - frequency: Data frequency - - Returns: - True if gap is expected (non-trading hours) - """ - # Convert to UTC if needed - if start.tzinfo is None: - start = start.replace(tzinfo=UTC) - if end.tzinfo is None: - end = end.replace(tzinfo=UTC) - - # Check for weekend - if start.weekday() == 4 and end.weekday() == 0: # Friday to Monday - return True - - # Check for overnight gap (4pm to 9:30am ET) - # Convert to ET (UTC-5 or UTC-4 for DST) - market_close_hour = 21 # 4pm ET in UTC (during standard time) - market_open_hour = 14 # 9:30am ET in UTC (during standard time) - - # Simplified check: if gap starts after market close and ends at/after market open - return start.hour >= market_close_hour or end.hour < market_open_hour - def summarize_gaps(self, gaps: list[DataGap]) -> dict[str, Any]: """ Summarize detected gaps. diff --git a/tests/test_gaps.py b/tests/test_gaps.py index b499608..61641d5 100644 --- a/tests/test_gaps.py +++ b/tests/test_gaps.py @@ -6,6 +6,7 @@ import pytest from ml4t.data.utils.gaps import DataGap, GapDetector +from ml4t.specs import FeedSpec class TestGapDetector: @@ -87,7 +88,7 @@ def test_detect_multiple_gaps(self) -> None: def test_minute_data_gaps(self) -> None: """Test gap detection in minute-level data.""" - base = datetime(2024, 1, 1, 14, 30, tzinfo=UTC) # 9:30 AM ET + base = datetime(2024, 1, 2, 14, 30, tzinfo=UTC) # 9:30 AM ET, a NYSE session timestamps = [ base, base + timedelta(minutes=1), @@ -105,7 +106,18 @@ def test_minute_data_gaps(self) -> None: ) detector = GapDetector() - gaps = detector.detect_gaps(df, frequency="minute") + gaps = detector.detect_gaps( + df, + frequency="minute", + feed_spec=FeedSpec( + time_contract_version=2, + data_frequency="minute", + timestamp_semantics="event_time", + bar_grid_anchor="session_open", + bar_label="left", + partial_bar_policy="include", + ), + ) assert len(gaps) == 1 assert gaps[0].missing_periods == 3 diff --git a/tests/test_intraday_schedule_gaps.py b/tests/test_intraday_schedule_gaps.py index b357ced..303b827 100644 --- a/tests/test_intraday_schedule_gaps.py +++ b/tests/test_intraday_schedule_gaps.py @@ -197,6 +197,7 @@ def test_registered_custom_break_grid_restarts_and_honors_partial_bar_policy(pol class DataGridRegressionCalendar(MarketCalendar): name = "DataGridRegression" + aliases = ["DataGridRegression"] tz = ZoneInfo("UTC") regular_market_times = { "market_open": ((None, time(9)),), @@ -217,3 +218,35 @@ class DataGridRegressionCalendar(MarketCalendar): ) feed = declaration("DataGridRegression", "hourly", partial_bar_policy=policy) assert sum(g.missing_periods for g in gaps(frame, feed)) == expected + + +def test_naive_ambiguous_local_clock_is_rejected_without_a_guessed_dst_fold(): + frame = ( + provider_frame("2025-11-02", "2025-11-02") + .filter( + (pl.col("timestamp").dt.hour() == 5) & pl.col("timestamp").dt.minute().is_in([30, 32]) + ) + .with_columns( + pl.col("timestamp").dt.convert_time_zone("America/New_York").dt.replace_time_zone(None) + ) + ) + with pytest.raises(pl.exceptions.ComputeError, match="ambiguous"): + gaps(frame, declaration("24/7", timezone="America/New_York")) + + +def test_a_frequency_conflict_and_event_count_feed_cannot_define_a_time_grid(): + frame = provider_frame().head(2) + with pytest.raises(ValueError, match="frequency"): + GapDetector().detect_gaps(frame, frequency="hourly", feed_spec=declaration("24/7")) + with pytest.raises(ValueError, match="fixed time bars"): + gaps(frame, declaration("24/7", bar_type="volume")) + + +def test_right_labeled_break_start_is_the_last_valid_morning_bar(): + frame = provider_frame().filter( + ((pl.col("timestamp").dt.hour() == 3) & (pl.col("timestamp").dt.minute() == 59)) + | ((pl.col("timestamp").dt.hour() == 4) & (pl.col("timestamp").dt.minute() == 0)) + ) + assert ( + gaps(frame, declaration("HKEX", timestamp_semantics="bar_close", bar_label="right")) == [] + ) From 3cd6684e24f8223f5be7a81b3cae0df248869310 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 11:23:38 -0400 Subject: [PATCH 13/21] test: expose nanosecond truncation and qualify partial close labels --- tests/test_intraday_schedule_gaps.py | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test_intraday_schedule_gaps.py b/tests/test_intraday_schedule_gaps.py index 303b827..64ab81a 100644 --- a/tests/test_intraday_schedule_gaps.py +++ b/tests/test_intraday_schedule_gaps.py @@ -250,3 +250,38 @@ def test_right_labeled_break_start_is_the_last_valid_morning_bar(): assert ( gaps(frame, declaration("HKEX", timestamp_semantics="bar_close", bar_label="right")) == [] ) + + +def test_sub_microsecond_offset_cannot_be_truncated_into_a_valid_grid_label(): + frame = ( + provider_frame() + .head(2) + .with_columns( + pl.col("timestamp").cast(pl.Datetime("ns", "UTC")) + pl.duration(nanoseconds=1) + ) + ) + with pytest.raises(ValueError, match="off.grid"): + gaps(frame, declaration("24/7")) + + +@pytest.mark.parametrize("policy,expected", [("include", 1), ("exclude", 0)]) +def test_right_labels_count_the_partial_shortened_close_at_its_actual_end(policy, expected): + frame = provider_frame("2025-07-03", "2025-07-07").filter( + ( + (pl.col("timestamp").dt.date() == date(2025, 7, 3)) + & (pl.col("timestamp").dt.hour() == 16) + & (pl.col("timestamp").dt.minute() == 30) + ) + | ( + (pl.col("timestamp").dt.date() == date(2025, 7, 7)) + & (pl.col("timestamp").dt.hour() == 14) + & (pl.col("timestamp").dt.minute() == 30) + ) + ) + feed = declaration( + frequency="hourly", + timestamp_semantics="bar_close", + bar_label="right", + partial_bar_policy=policy, + ) + assert sum(g.missing_periods for g in gaps(frame, feed)) == expected From 128f4c215d1696882e40eb390318b7fbd6af9b4b Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 11:25:01 -0400 Subject: [PATCH 14/21] fix: count expected bar positions without expanding every missing timestamp --- src/ml4t/data/utils/gaps.py | 65 ++++++++++++++++++++-------- tests/test_intraday_schedule_gaps.py | 24 ++++++++++ 2 files changed, 72 insertions(+), 17 deletions(-) diff --git a/src/ml4t/data/utils/gaps.py b/src/ml4t/data/utils/gaps.py index 87ac122..1dd92a7 100644 --- a/src/ml4t/data/utils/gaps.py +++ b/src/ml4t/data/utils/gaps.py @@ -192,6 +192,8 @@ def _detect_intraday_gaps( feed: FeedSpec, ) -> list[DataGap]: interval = self._intraday_interval(frequency) + if interval is None: + raise ValueError("unsupported intraday bar frequency") required = ( "data_frequency", "timestamp_semantics", @@ -215,6 +217,11 @@ def _detect_intraday_gaps( raise TypeError(f"'{timestamp_col}' must contain Datetime values for an intraday grid") if df[timestamp_col].null_count(): raise ValueError(f"'{timestamp_col}' must not contain null timestamps") + if ( + dtype.time_unit == "ns" + and df.select((pl.col(timestamp_col).dt.nanosecond() % 1000 != 0).any()).item() + ): + raise ValueError("intraday observations contain off-grid sub-microsecond timestamps") if dtype.time_zone is None and feed.timezone is None: raise ValueError("naive intraday timestamps require a declared timezone") if df.is_empty(): @@ -230,7 +237,8 @@ def _detect_intraday_gaps( ) import pandas as pd - grids = [] + grid_intervals: list[tuple[datetime, datetime, int, int]] = [] + total_bars = 0 for _, row in schedule.iterrows(): opening = row["market_open"].to_pydatetime().astimezone(UTC) closing = row["market_close"].to_pydatetime().astimezone(UTC) @@ -246,23 +254,46 @@ def _detect_intraday_gaps( count += 1 if count <= 0: continue - labels = pl.datetime_range( - opening, - opening + count * interval, - interval=interval, - closed="left", - eager=True, - ) - if feed.bar_label == BarLabel.RIGHT: - labels = (labels + interval).clip(upper_bound=closing) - grids.append(labels) - expected = pl.concat(grids).unique().sort().to_list() if grids else [] - expected_set = set(expected) - if any(value not in expected_set for value in observed): - raise ValueError("intraday observations contain off-grid timestamps") + if grid_intervals and opening < grid_intervals[-1][1]: + raise ValueError("calendar returned overlapping tradable intervals") + grid_intervals.append((opening, closing, count, total_bars)) + total_bars += count + right_labeled = feed.bar_label == BarLabel.RIGHT + boundaries = [row[1] if right_labeled else row[0] for row in grid_intervals] + + def bar_index(timestamp: datetime) -> int: + index = ( + bisect_left(boundaries, timestamp) + if right_labeled + else bisect_right(boundaries, timestamp) - 1 + ) + if index < 0 or index >= len(grid_intervals): + raise ValueError("intraday observations contain off-grid timestamps") + opening, closing, count, offset = grid_intervals[index] + if right_labeled and timestamp == closing: + if (closing - opening) % interval == timedelta(0) or ( + feed.partial_bar_policy == PartialBarPolicy.INCLUDE + ): + return offset + count - 1 + raise ValueError("intraday observations contain off-grid partial-close timestamps") + position, remainder = divmod(timestamp - opening, interval) + if right_labeled: + position -= 1 + if ( + remainder + or timestamp < opening + or timestamp >= closing + or not 0 <= position < count + ): + raise ValueError("intraday observations contain off-grid timestamps") + return offset + position + + positions = [bar_index(timestamp) for timestamp in observed] gaps = [] - for current, following in zip(observed, observed[1:]): - missing = bisect_left(expected, following) - bisect_right(expected, current) + for current, following, left_index, right_index in zip( + observed, observed[1:], positions, positions[1:] + ): + missing = right_index - left_index - 1 if missing > 0: gaps.append(DataGap(current, following, missing, following - current, frequency)) return gaps diff --git a/tests/test_intraday_schedule_gaps.py b/tests/test_intraday_schedule_gaps.py index 64ab81a..816100f 100644 --- a/tests/test_intraday_schedule_gaps.py +++ b/tests/test_intraday_schedule_gaps.py @@ -285,3 +285,27 @@ def test_right_labels_count_the_partial_shortened_close_at_its_actual_end(policy partial_bar_policy=policy, ) assert sum(g.missing_periods for g in gaps(frame, feed)) == expected + + +def test_excluded_partial_close_is_not_accepted_as_the_last_full_bar_label(): + frame = provider_frame("2025-07-03", "2025-07-03").filter( + ((pl.col("timestamp").dt.hour() == 16) & (pl.col("timestamp").dt.minute() == 30)) + | ((pl.col("timestamp").dt.hour() == 17) & (pl.col("timestamp").dt.minute() == 0)) + ) + assert ( + gaps( + frame, + declaration(frequency="hourly", timestamp_semantics="bar_close", bar_label="right"), + ) + == [] + ) + with pytest.raises(ValueError, match="off.grid"): + gaps( + frame, + declaration( + frequency="hourly", + timestamp_semantics="bar_close", + bar_label="right", + partial_bar_policy="exclude", + ), + ) From ed602d5c10aac3017bcca146be091c1b92c1aa49 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 11:25:41 -0400 Subject: [PATCH 15/21] docs: state the legacy updater intraday declaration limitation --- docs/user-guide/incremental-updates.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/incremental-updates.md b/docs/user-guide/incremental-updates.md index b51f04f..ab4a680 100644 --- a/docs/user-guide/incremental-updates.md +++ b/docs/user-guide/incremental-updates.md @@ -49,9 +49,10 @@ ml4t-data update --symbol AAPL --no-fill-gaps --storage-path ./data ``` Daily detection counts missing sessions from the selected exchange schedule, not elapsed days. -The updater currently selects NYSE for non-crypto data and 24/7 for crypto. Intraday detection still -uses approximate US overnight/weekend rules. It is not an exchange-calendar reconstruction. Use -[trading-session completion](sessions.md) when exact intraday schedules are required. +The updater currently selects NYSE for non-crypto data and 24/7 for crypto. Its legacy intraday +path lacks the complete grid declaration required by the detector and now fails clearly; use +`--no-fill-gaps` or `fill_gaps=False` for those updates. Direct detection with a complete declaration +is described below. [Trading-session completion](sessions.md) is a separate one-minute operation. Detection does not itself fill or modify observations. The updater's separately enabled forward fill uses a regular elapsed-time grid, not the selected exchange schedule, and can insert closed From 41f932a8b3470c23a47e84f57a88df08a0fb7e8c Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 11:26:26 -0400 Subject: [PATCH 16/21] test: expose declared intraday frequency being treated as daily labels --- tests/test_intraday_schedule_gaps.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_intraday_schedule_gaps.py b/tests/test_intraday_schedule_gaps.py index 816100f..2787711 100644 --- a/tests/test_intraday_schedule_gaps.py +++ b/tests/test_intraday_schedule_gaps.py @@ -42,6 +42,16 @@ def test_unqualified_summer_outage_cannot_silently_return_no_gap(): GapDetector().detect_gaps(frame, frequency="minute") +def test_declared_intraday_frequency_cannot_silently_fall_back_to_daily_labels(): + frame = provider_frame().filter( + (pl.col("timestamp").dt.hour() == 13) & pl.col("timestamp").dt.minute().is_in([30, 32]) + ) + found = GapDetector().detect_gaps(frame, feed_spec=declaration()) + assert sum(g.missing_periods for g in found) == 1 + with pytest.raises(ValueError, match="frequency"): + GapDetector().detect_gaps(frame, frequency="daily", feed_spec=declaration()) + + @pytest.mark.parametrize("day,hour,minute", [("2025-07-07", 13, 30), ("2025-01-07", 14, 30)]) def test_declared_nyse_dst_and_winter_outage_each_count_one_bar(day, hour, minute): frame = provider_frame(day, day).filter( From feec542bf0b4fc1a2e594807f4ce5dfca6367608 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 11:26:50 -0400 Subject: [PATCH 17/21] fix: inherit declared bar frequency and reject incompatible explicit overrides --- docs/user-guide/incremental-updates.md | 3 ++- src/ml4t/data/utils/gaps.py | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/incremental-updates.md b/docs/user-guide/incremental-updates.md index ab4a680..1c13d6d 100644 --- a/docs/user-guide/incremental-updates.md +++ b/docs/user-guide/incremental-updates.md @@ -106,7 +106,8 @@ Intraday classification requires the complete version-2 declaration above. `even does not choose a bar side. `bar_close` requires `bar_label="right"`. Naive timestamps also require `timezone`; ambiguous/nonexistent local clocks fail rather than selecting a DST interpretation. Supported fixed intervals use positive minute/hour counts, such as `5minute`, `15min` or `hourly`. -The requested and declared frequencies must agree. Event-count bars and session-label timestamps +Omitted `frequency` inherits the feed declaration, otherwise defaults to daily. An explicit +frequency must agree with the declaration. Event-count bars and session-label timestamps cannot define an intraday grid. The grid starts at each authoritative tradable interval's open and restarts after lunch breaks. diff --git a/src/ml4t/data/utils/gaps.py b/src/ml4t/data/utils/gaps.py index 1dd92a7..2f507a0 100644 --- a/src/ml4t/data/utils/gaps.py +++ b/src/ml4t/data/utils/gaps.py @@ -78,7 +78,7 @@ def __init__(self, tolerance: float = DEFAULT_TOLERANCE) -> None: def detect_gaps( self, df: pl.DataFrame, - frequency: str = "daily", + frequency: str | None = None, timestamp_col: str = "timestamp", is_crypto: bool = False, *, @@ -90,7 +90,7 @@ def detect_gaps( Args: df: DataFrame with time series data - frequency: Data frequency (minute, hourly, daily, etc.) + frequency: Data frequency; inherit the feed declaration, otherwise daily. timestamp_col: Name of timestamp column is_crypto: If True, expect 24/7 data; if False, market hours only calendar: Schedule override; None disables schedule-based detection. @@ -100,6 +100,8 @@ def detect_gaps( List of detected gaps """ feed = FeedSpec.from_any(feed_spec) + if frequency is None: + frequency = str(feed.data_frequency) if feed.data_frequency is not None else "daily" # Preserve the old crypto hint only when the feed has no declaration. if ( frequency.lower() in {"daily", "1day"} @@ -114,6 +116,16 @@ def detect_gaps( selected_calendar = feed.resolve_calendar(calendar=calendar) if selected_calendar is None: return [] + declared_interval = ( + self._intraday_interval(str(feed.data_frequency)) + if feed.data_frequency is not None + else None + ) + if ( + declared_interval is not None + and self._intraday_interval(frequency) != declared_interval + ): + raise ValueError("declared grid data_frequency must match the requested frequency") if frequency.lower() in {"daily", "1day"}: return self._detect_daily_gaps(df, timestamp_col, frequency, selected_calendar, feed) if self._intraday_interval(frequency) is not None: From 315fcb614dbceb262df55852fec77b915e5911bd Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 11:28:14 -0400 Subject: [PATCH 18/21] test: expose scheduled calendar interruptions counted as missing bars --- tests/test_intraday_schedule_gaps.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_intraday_schedule_gaps.py b/tests/test_intraday_schedule_gaps.py index 2787711..385153b 100644 --- a/tests/test_intraday_schedule_gaps.py +++ b/tests/test_intraday_schedule_gaps.py @@ -319,3 +319,26 @@ def test_excluded_partial_close_is_not_accepted_as_the_last_full_bar_label(): partial_bar_policy="exclude", ), ) + + +def test_registered_calendar_halt_is_not_missing_observations(): + from pandas_market_calendars.market_calendar import MarketCalendar + + class DataGridHaltCalendar(MarketCalendar): + name = "DataGridHalt" + aliases = ["DataGridHalt"] + tz = ZoneInfo("UTC") + regular_market_times = { + "market_open": ((None, time(9)),), + "market_close": ((None, time(14)),), + } + + @property + def interruptions(self): + return [("2025-07-07", time(10), time(10, 30))] + + frame = provider_frame().filter( + ((pl.col("timestamp").dt.hour() == 9) & (pl.col("timestamp").dt.minute() == 59)) + | ((pl.col("timestamp").dt.hour() == 10) & (pl.col("timestamp").dt.minute() == 30)) + ) + assert gaps(frame, declaration("DataGridHalt")) == [] From e7dbc5f98cf896df509c376e24bd341af8e8feb5 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 11:28:31 -0400 Subject: [PATCH 19/21] fix: exclude authoritative calendar interruptions from expected intraday bars --- src/ml4t/data/utils/gaps.py | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/ml4t/data/utils/gaps.py b/src/ml4t/data/utils/gaps.py index 2f507a0..d659039 100644 --- a/src/ml4t/data/utils/gaps.py +++ b/src/ml4t/data/utils/gaps.py @@ -246,20 +246,43 @@ def _detect_intraday_gaps( schedule = assigner.calendar.schedule( start_date=(start - timedelta(days=2)).date(), end_date=(end + timedelta(days=2)).date(), + interruptions=True, ) import pandas as pd grid_intervals: list[tuple[datetime, datetime, int, int]] = [] total_bars = 0 + closure_columns = [("break_start", "break_end")] if "break_start" in schedule else [] + closure_columns.extend( + (name, name.replace("interruption_start_", "interruption_end_")) + for name in schedule.columns + if name.startswith("interruption_start_") + ) for _, row in schedule.iterrows(): opening = row["market_open"].to_pydatetime().astimezone(UTC) closing = row["market_close"].to_pydatetime().astimezone(UTC) intervals = [(opening, closing)] - if "break_start" in schedule.columns and not pd.isna(row["break_start"]): - intervals = [ - (opening, row["break_start"].to_pydatetime().astimezone(UTC)), - (row["break_end"].to_pydatetime().astimezone(UTC), closing), - ] + closures = [] + for start_column, end_column in closure_columns: + left, right = row[start_column], row[end_column] + if pd.isna(left) and pd.isna(right): + continue + if pd.isna(left) or pd.isna(right) or left >= right: + raise ValueError("calendar returned invalid closure boundaries") + closures.append( + (left.to_pydatetime().astimezone(UTC), right.to_pydatetime().astimezone(UTC)) + ) + for closure_start, closure_end in sorted(closures): + tradable = [] + for left, right in intervals: + if closure_end <= left or closure_start >= right: + tradable.append((left, right)) + else: + if left < closure_start: + tradable.append((left, closure_start)) + if closure_end < right: + tradable.append((closure_end, right)) + intervals = tradable for opening, closing in intervals: count, remainder = divmod(closing - opening, interval) if remainder and feed.partial_bar_policy == PartialBarPolicy.INCLUDE: From af776eebf5cd00f049654fd8938b88c184d1d160 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 11:29:16 -0400 Subject: [PATCH 20/21] fix: accept empty calendar breaks on shortened futures sessions --- docs/user-guide/incremental-updates.md | 3 ++- src/ml4t/data/utils/gaps.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/incremental-updates.md b/docs/user-guide/incremental-updates.md index 1c13d6d..33b7f82 100644 --- a/docs/user-guide/incremental-updates.md +++ b/docs/user-guide/incremental-updates.md @@ -110,7 +110,8 @@ Omitted `frequency` inherits the feed declaration, otherwise defaults to daily. frequency must agree with the declaration. Event-count bars and session-label timestamps cannot define an intraday grid. -The grid starts at each authoritative tradable interval's open and restarts after lunch breaks. +The grid starts at each authoritative tradable interval's open and restarts after lunch breaks +and declared calendar interruptions. Zero-length breaks on shortened sessions remove no bars. `partial_bar_policy="include"` retains a shorter final bar; `"exclude"` omits it. Right labels name the bar's end, including the actual shortened close. Holidays, early closes, DST, overnight sessions and registered custom calendars use their selected schedule, not the observed rows. Off-grid diff --git a/src/ml4t/data/utils/gaps.py b/src/ml4t/data/utils/gaps.py index d659039..6cc343b 100644 --- a/src/ml4t/data/utils/gaps.py +++ b/src/ml4t/data/utils/gaps.py @@ -267,8 +267,10 @@ def _detect_intraday_gaps( left, right = row[start_column], row[end_column] if pd.isna(left) and pd.isna(right): continue - if pd.isna(left) or pd.isna(right) or left >= right: + if pd.isna(left) or pd.isna(right) or left > right: raise ValueError("calendar returned invalid closure boundaries") + if left == right: + continue closures.append( (left.to_pydatetime().astimezone(UTC), right.to_pydatetime().astimezone(UTC)) ) From 2e239ce6606e6bd89593db8c790e4abf919e8409 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Sat, 17 Oct 2026 12:00:14 -0400 Subject: [PATCH 21/21] test: qualify legacy intraday gap cases against declared bar grids --- tests/test_utils_gaps.py | 211 ++++++++++++++------------------------- 1 file changed, 73 insertions(+), 138 deletions(-) diff --git a/tests/test_utils_gaps.py b/tests/test_utils_gaps.py index 327b817..458c675 100644 --- a/tests/test_utils_gaps.py +++ b/tests/test_utils_gaps.py @@ -3,11 +3,35 @@ from datetime import datetime, timedelta import polars as pl +import pytest +from ml4t.data.providers import SyntheticProvider from ml4t.data.utils.gaps import ( DataGap, GapDetector, ) +from ml4t.specs import FeedSpec + + +def intraday_feed(frequency, calendar="24/7"): + return FeedSpec( + time_contract_version=2, + calendar=calendar, + timezone="UTC", + data_frequency=frequency, + timestamp_semantics="event_time", + bar_grid_anchor="session_open", + bar_label="left", + partial_bar_policy="include", + ) + + +def intraday_rows(timestamps): + with SyntheticProvider(calendar_mode="continuous") as provider: + frame = provider.fetch_ohlcv( + "SYNTH", timestamps[0].date().isoformat(), timestamps[-1].date().isoformat(), "minute" + ) + return frame.filter(pl.col("timestamp").dt.replace_time_zone(None).is_in(timestamps)) class TestDataGap: @@ -161,48 +185,20 @@ def test_detect_gaps_custom_timestamp_column(self): assert len(gaps) >= 1 def test_detect_gaps_hourly_frequency(self): - """Test gap detection with hourly frequency.""" + """A three-hour continuous interval must retain two missing hourly bars.""" detector = GapDetector() - df = pl.DataFrame( - { - "timestamp": [ - datetime(2024, 1, 1, 10, 0), - datetime(2024, 1, 1, 13, 0), # Missing 3 hours - ], - "value": [10.0, 40.0], - } - ) - - gaps = detector.detect_gaps(df, frequency="hourly", is_crypto=True) - - # Should detect a gap (crypto = 24/7) - assert len(gaps) >= 1 + df = intraday_rows([datetime(2024, 1, 1, 10), datetime(2024, 1, 1, 13)]) + gaps = detector.detect_gaps(df, feed_spec=intraday_feed("hourly")) + assert [gap.missing_periods for gap in gaps] == [2] def test_detect_gaps_crypto_vs_traditional(self): - """Test difference between crypto (24/7) and traditional market hours.""" + """An exchange weekend must not erase a continuous-feed outage.""" detector = GapDetector() - - # Weekend data (Saturday to Monday) - df = pl.DataFrame( - { - "timestamp": [ - datetime(2024, 1, 6, 15, 0), # Saturday - datetime(2024, 1, 8, 9, 0), # Monday - ], - "value": [10.0, 20.0], - } - ) - - # For crypto (24/7), this should be a gap - crypto_gaps = detector.detect_gaps(df, frequency="hourly", is_crypto=True) - - # For traditional markets, weekend gap might be expected - traditional_gaps = detector.detect_gaps(df, frequency="hourly", is_crypto=False) - - # Results might differ (crypto should detect more gaps over weekends) - # This tests the logic even if implementation varies - assert isinstance(crypto_gaps, list) - assert isinstance(traditional_gaps, list) + df = intraday_rows([datetime(2024, 1, 5, 20, 59), datetime(2024, 1, 8, 14, 30)]) + crypto_gaps = detector.detect_gaps(df, feed_spec=intraday_feed("minute")) + traditional_gaps = detector.detect_gaps(df, feed_spec=intraday_feed("minute", "NYSE")) + assert [gap.missing_periods for gap in crypto_gaps] == [3930] + assert traditional_gaps == [] def test_detect_gaps_unsorted_data(self): """Test gap detection handles unsorted data.""" @@ -224,22 +220,11 @@ def test_detect_gaps_unsorted_data(self): assert isinstance(gaps, list) def test_detect_gaps_minute_frequency(self): - """Test gap detection with minute frequency.""" + """A five-minute continuous interval must retain four missing minute bars.""" detector = GapDetector() - df = pl.DataFrame( - { - "timestamp": [ - datetime(2024, 1, 1, 9, 30, 0), - datetime(2024, 1, 1, 9, 35, 0), # 5 minute gap - ], - "value": [100.0, 105.0], - } - ) - - gaps = detector.detect_gaps(df, frequency="minute", is_crypto=True) - - # Should detect multiple 1-minute gaps - assert len(gaps) >= 1 + df = intraday_rows([datetime(2024, 1, 1, 9, 30), datetime(2024, 1, 1, 9, 35)]) + gaps = detector.detect_gaps(df, feed_spec=intraday_feed("minute")) + assert [gap.missing_periods for gap in gaps] == [4] def test_periods_per_day_constants(self): """Test PERIODS_PER_DAY constants are reasonable.""" @@ -313,107 +298,57 @@ def test_monthly_frequency(self): # Should detect gaps in monthly data assert isinstance(gaps, list) - def test_tolerance_effect(self): - """Test that tolerance affects gap detection.""" - # High tolerance should detect fewer gaps - lenient_detector = GapDetector(tolerance=0.5) # 50% tolerance - strict_detector = GapDetector(tolerance=0.01) # 1% tolerance - - df = pl.DataFrame( - { - "timestamp": [ - datetime(2024, 1, 1, 0, 0), - datetime(2024, 1, 1, 1, 30), # 1.5 hours (slight gap) - ], - "value": [10.0, 20.0], - } - ) - - lenient_gaps = lenient_detector.detect_gaps(df, frequency="hourly", is_crypto=True) - strict_gaps = strict_detector.detect_gaps(df, frequency="hourly", is_crypto=True) - - # Strict detector should potentially detect more gaps than lenient - assert isinstance(lenient_gaps, list) - assert isinstance(strict_gaps, list) + @pytest.mark.parametrize("tolerance", [0.5, 0.01]) + def test_tolerance_cannot_reclassify_off_grid_timestamps(self, tolerance): + """Elapsed-time tolerance must not admit half-hour labels to an hourly grid.""" + df = intraday_rows([datetime(2024, 1, 1), datetime(2024, 1, 1, 1, 30)]) + with pytest.raises(ValueError, match="off-grid"): + GapDetector(tolerance=tolerance).detect_gaps(df, feed_spec=intraday_feed("hourly")) class TestGapDetectorIntegration: """Integration tests for GapDetector.""" def test_realistic_stock_data_gaps(self): - """Test gap detection on realistic stock market data.""" + """An hourly NYSE grid excludes the weekend but retains Monday's missing bar.""" detector = GapDetector() - - # Simulate stock data with weekend gap - df = pl.DataFrame( - { - "timestamp": [ - datetime(2024, 1, 5, 15, 0), # Friday market close - datetime(2024, 1, 8, 9, 30), # Monday market open - datetime(2024, 1, 8, 10, 30), # Monday 1 hour later - datetime(2024, 1, 8, 12, 0), # Monday with small gap - ], - "value": [100.0, 102.0, 103.0, 104.0], - } + df = intraday_rows( + [ + datetime(2024, 1, 5, 20, 30), + datetime(2024, 1, 8, 14, 30), + datetime(2024, 1, 8, 15, 30), + datetime(2024, 1, 8, 17, 30), + ] ) - - gaps = detector.detect_gaps(df, frequency="hourly", is_crypto=False) - - # Should handle weekend gaps appropriately for traditional markets - assert isinstance(gaps, list) + gaps = detector.detect_gaps(df, feed_spec=intraday_feed("hourly", "NYSE")) + assert [gap.missing_periods for gap in gaps] == [1] + assert gaps[0].start.hour == 15 + assert gaps[0].end.hour == 17 def test_crypto_24_7_data_gaps(self): - """Test gap detection on 24/7 crypto data.""" + """Separate outages on a continuous hourly grid must keep their exact sizes.""" detector = GapDetector() - - # Simulate crypto data with regular hourly gaps base_time = datetime(2024, 1, 1, 0, 0) - timestamps = [] - values = [] - - # Add hourly data with some gaps - for hour in [0, 1, 2, 5, 6, 9]: # Missing hours 3,4 and 7,8 - timestamps.append(base_time + timedelta(hours=hour)) - values.append(50000.0 + hour * 100) - - df = pl.DataFrame( - { - "timestamp": timestamps, - "value": values, - } - ) - - gaps = detector.detect_gaps(df, frequency="hourly", is_crypto=True) - - # Should detect the missing hours in 24/7 crypto data - assert len(gaps) >= 1 + df = intraday_rows([base_time + timedelta(hours=hour) for hour in [0, 1, 2, 5, 6, 9]]) + gaps = detector.detect_gaps(df, feed_spec=intraday_feed("hourly")) + assert [gap.missing_periods for gap in gaps] == [2, 2] def test_mixed_frequency_analysis(self): - """Test gap detection across different frequencies on same data.""" + """Observations valid on a minute grid need not belong to an hourly grid.""" detector = GapDetector() - - # Create data with gaps at different scales - df = pl.DataFrame( - { - "timestamp": [ - datetime(2024, 1, 1, 9, 0), - datetime(2024, 1, 1, 9, 5), # 5 minutes later - datetime(2024, 1, 1, 11, 0), # 2 hour gap - datetime(2024, 1, 2, 9, 0), # Next day - ], - "value": [100.0, 101.0, 103.0, 110.0], - } + df = intraday_rows( + [ + datetime(2024, 1, 1, 9, 0), + datetime(2024, 1, 1, 9, 5), + datetime(2024, 1, 1, 11, 0), + datetime(2024, 1, 2, 9, 0), + ] ) - - # Test different frequencies - minute_gaps = detector.detect_gaps(df, frequency="minute", is_crypto=True) - hourly_gaps = detector.detect_gaps(df, frequency="hourly", is_crypto=True) - daily_gaps = detector.detect_gaps(df, frequency="daily", is_crypto=True) - - # Different frequencies should detect different gap patterns - assert isinstance(minute_gaps, list) - assert isinstance(hourly_gaps, list) - assert isinstance(daily_gaps, list) + minute_gaps = detector.detect_gaps(df, feed_spec=intraday_feed("minute")) + assert [gap.missing_periods for gap in minute_gaps] == [4, 114, 1319] + with pytest.raises(ValueError, match="off-grid"): + detector.detect_gaps(df, feed_spec=intraday_feed("hourly")) + assert detector.detect_gaps(df, frequency="daily", calendar="24/7") == [] def test_large_dataset_performance(self): """Test gap detection performance on larger dataset."""