From 362cbe7b7bcc250f5a4d0bcf4d2d7b5de0dd1644 Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 15 Oct 2026 17:55:11 -0400 Subject: [PATCH 1/2] test: capture calendar intent and serialized grid contract gaps --- tests/test_time_contract.py | 143 ++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 tests/test_time_contract.py diff --git a/tests/test_time_contract.py b/tests/test_time_contract.py new file mode 100644 index 0000000..64fc2d2 --- /dev/null +++ b/tests/test_time_contract.py @@ -0,0 +1,143 @@ +from copy import deepcopy +from dataclasses import asdict, replace +from types import SimpleNamespace + +import pytest + +from ml4t.specs import ( + FeedSpec, + MarketDataSemantics, + MarketDataSpec, + read_spec_payload, + write_spec_payload, +) + + +@pytest.mark.parametrize("suffix", [".json", ".yaml"]) +@pytest.mark.parametrize( + "declaration, expected", + [({}, "NYSE"), ({"calendar": None}, None), ({"calendar": "CME_Equity"}, "CME_Equity")], +) +def test_artifact_handoff_preserves_calendar_intent(tmp_path, suffix, declaration, expected): + semantics = MarketDataSemantics.from_mapping({"time_contract_version": 2, **declaration}) + artifact = MarketDataSpec(artifact_id="bars", semantics=semantics) + path = tmp_path / f"bars{suffix}" + write_spec_payload(artifact, path) + payload = read_spec_payload(path) + assert ("calendar" in payload["semantics"]) == ("calendar" in declaration) + loaded = MarketDataSpec.from_mapping(payload) + feed = loaded.to_feed_spec() + assert feed.resolve_calendar() == expected + assert feed.resolve_calendar(calendar=None) is None + assert feed.resolve_calendar(calendar="LSE") == "LSE" + assert feed.to_dict().get("calendar") == declaration.get("calendar") + assert ("calendar" in feed.to_dict()) == ("calendar" in declaration) + + +@pytest.mark.parametrize( + "declaration, expected", + [({}, "NYSE"), ({"calendar": None}, None), ({"calendar": "24/7"}, "24/7")], +) +def test_feed_copy_and_projection_do_not_reenable_disabled_calendars(declaration, expected): + feed = FeedSpec(time_contract_version=2, **declaration) + copies = [ + deepcopy(feed), + replace(feed, price_col="mid"), + FeedSpec.from_mapping(asdict(feed)), + FeedSpec.from_any(SimpleNamespace(metadata=feed)), + FeedSpec.from_mapping(feed.to_dict()), + ] + for copied in copies: + assert copied.resolve_calendar() == expected + cleared = feed.with_overrides(calendar=None) + assert cleared.resolve_calendar() is None + assert feed.resolve_calendar() == expected + assert cleared.to_dict()["calendar"] is None + + +def test_legacy_null_remains_unspecified_until_explicit_migration(): + legacy = FeedSpec.from_mapping({"calendar": None}) + assert legacy.calendar is None + assert legacy.resolve_calendar() == "NYSE" + assert legacy.resolve_calendar(calendar=None) is None + assert FeedSpec(calendar="NYSE").with_overrides(calendar=None).calendar == "NYSE" + migrated = FeedSpec.from_mapping({**legacy.to_dict(), "time_contract_version": 2}) + assert migrated.resolve_calendar() is None + assert FeedSpec(time_contract_version=2).resolve_calendar() == "NYSE" + + +def test_grid_declarations_survive_real_serialized_feed_handoff(tmp_path): + declaration = { + "time_contract_version": 2, + "calendar": "NYSE", + "data_frequency": "1h", + "timezone": "America/New_York", + "timestamp_semantics": "bar_close", + "bar_grid_anchor": "session_open", + "bar_label": "right", + "partial_bar_policy": "include", + } + artifact = MarketDataSpec(artifact_id="hourly", semantics=MarketDataSemantics(**declaration)) + path = tmp_path / "hourly.json" + write_spec_payload(artifact, path) + feed = FeedSpec.from_mapping(read_spec_payload(path)) + for name, expected in declaration.items(): + assert getattr(feed, name) == expected + assert {name: feed.to_dict()[name] for name in declaration} == declaration + + +@pytest.mark.parametrize( + "changes, error", + [ + ({"time_contract_version": 3}, "time_contract_version"), + ({"time_contract_version": True}, "time_contract_version"), + ({"calendar": ""}, "calendar"), + ({"calendar": False}, "calendar"), + ({"bar_grid_anchor": "first_observation"}, "bar_grid_anchor"), + ({"bar_label": "middle"}, "bar_label"), + ({"partial_bar_policy": "guess"}, "partial_bar_policy"), + ({"bar_label": "left"}, "bar_close"), + ({"timestamp_semantics": "session_label"}, "session_label"), + ], +) +@pytest.mark.parametrize("contract_type", [FeedSpec, MarketDataSemantics]) +def test_invalid_declarations_fail_without_mutating_input(changes, error, contract_type): + payload = { + "time_contract_version": 2, + "calendar": "NYSE", + "data_frequency": "1h", + "timestamp_semantics": "bar_close", + "bar_grid_anchor": "session_open", + "bar_label": "right", + "partial_bar_policy": "include", + **changes, + } + original = deepcopy(payload) + with pytest.raises(ValueError, match=error): + contract_type.from_mapping(payload) + assert payload == original + + +def test_legacy_artifact_serialization_does_not_acquire_new_time_defaults(): + payload = MarketDataSpec(artifact_id="legacy").to_dict()["semantics"] + assert payload == { + "data_frequency": None, + "calendar": None, + "timezone": None, + "timestamp_semantics": None, + "session_start_time": None, + "bar_type": None, + } + + +def test_window_units_do_not_alias_rows_sessions_and_elapsed_time(): + from ml4t.specs import WindowUnit + + assert {unit.value for unit in WindowUnit} == { + "observation", + "expected_bar", + "session", + "elapsed", + } + with pytest.raises(ValueError): + WindowUnit("day") From ee125aab3878e3d3f98254e91b59c8fe25bfb6ab Mon Sep 17 00:00:00 2001 From: Stefan Jansen Date: Thu, 15 Oct 2026 18:02:35 -0400 Subject: [PATCH 2/2] feat: preserve calendar intent in versioned time contracts --- docs/contracts.md | 72 ++++++++++++++++++++++ src/ml4t/specs/__init__.py | 14 +++++ src/ml4t/specs/market_data.py | 99 ++++++++++++++++++++++++++---- src/ml4t/specs/time.py | 109 ++++++++++++++++++++++++++++++++++ tests/test_time_contract.py | 78 +++++++++++++++++++++++- 5 files changed, 358 insertions(+), 14 deletions(-) create mode 100644 src/ml4t/specs/time.py diff --git a/docs/contracts.md b/docs/contracts.md index 0ebf9ec..5d4d7e8 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -5,6 +5,78 @@ FeedSpec and MarketDataSpec define column mappings, frequency, timestamps, calendars, storage, and provenance. Consumers validate the contract before interpreting a table. +### Calendar selection and temporal contract versions + +New time-aware consumers use `time_contract_version=2`. This temporal version is independent of +the artifact's `version`. It preserves an omitted calendar separately from an explicit `None`: + +| Declaration | Effective calendar | +|---|---| +| Explicit function argument | That name, or no calendar when `None` | +| Argument omitted, metadata declares a calendar | The metadata value, including explicit `None` in version 2 | +| Neither declares a calendar | `NYSE` | + +Use the same `calendar` keyword in functions, configs and serialized metadata. Consumers use +`UNSET_CALENDAR` as their omitted-argument default and call `FeedSpec.resolve_calendar`. Ordinary +callers do not need the marker. `None` means observation order/row counts; a named `24/7` calendar +still defines expected times and can detect missing observations. + +```python +from ml4t.specs import FeedSpec, UNSET_CALENDAR + +metadata = FeedSpec(time_contract_version=2, calendar="24/7") +assert metadata.resolve_calendar() == "24/7" +assert metadata.resolve_calendar(calendar=None) is None +assert FeedSpec(time_contract_version=2).resolve_calendar() == "NYSE" + +disabled = metadata.with_overrides(calendar=None) +assert disabled.to_dict()["calendar"] is None +inherited = disabled.with_overrides(calendar=UNSET_CALENDAR) +assert "calendar" not in inherited.to_dict() +``` + +Serialize with `FeedSpec.to_dict()`, `MarketDataSemantics.to_dict()` or `MarketDataSpec.to_dict()` +before using the shared JSON/YAML writer. These methods omit an undeclared calendar and retain +explicit null. `dataclasses.asdict` is an in-memory projection, not the version-2 wire format. +Copied/replaced version-2 objects and nested artifact-to-feed projections preserve declaration +presence. Consumers must resolve the calendar before passing it to a provider; the unset marker is +not a provider name. Specs validates names structurally; consumers validate provider availability +and the requested schedule without substituting a different exchange. + +Constructors and payloads without a temporal version retain version 1. Its null metadata means +unspecified and its `with_overrides(calendar=None)` retains the previous calendar. Explicit arguments +to the new resolver still take precedence, including `None`. Migrating a legacy payload requires +choosing whether an old null meant no declaration or an intentional opt-out: remove the key for +inheritance, or retain null for disable, then set `time_contract_version=2`. Do not relabel legacy +payloads automatically. Non-calendar null overrides retain the legacy no-override convention. + +### Fixed-bar grid declarations + +Version 2 adds explicit `bar_grid_anchor`, `bar_label` and `partial_bar_policy` fields. They are +optional metadata, without inferred defaults. A consumer performing grid validation must require +the fields it needs along with declared `data_frequency`, timestamp semantics and timezone for +naive timestamps. Specs does not infer a frequency or compute market schedules. + +| Field | Supported declaration and meaning | +|---|---| +| `bar_grid_anchor` | `session_open`: start the grid at each authoritative tradable interval's open, including a reopen after a break. A continuous daily schedule uses its declared daily boundary. | +| `bar_label` | `left` or `right`: which boundary the bar timestamp names. `bar_close` timestamp semantics requires `right`; `event_time` alone does not choose a side. | +| `partial_bar_policy` | `include` or `exclude`: retain or omit a final interval shorter than the declared duration at a break or close. | + +Session labels are identifiers, not intraday bar timestamps; they cannot declare an intraday grid. +Calendar closures and breaks come from the selected schedule, not missing observations. The metadata +does not authorize filling prices, changing row counts, or treating event-count bars as fixed-time +bars. Validation of invalid declarations occurs before a contract is returned or persisted. + +`WindowUnit` distinguishes `observation` (input rows), `expected_bar` (declared grid steps), +`session` (authoritative trading sessions) and `elapsed` (an explicit duration). It performs no +conversion. Consumers must retain the selected unit and must not reinterpret, for example, a +session-count window as a count of observed dates when a scheduled session is absent. Annualization +is a separately declared numerical convention; a calendar-free workflow does not imply NYSE scaling. + +The temporal contract does not change the defaults of already released consumers. Each library must +adopt the resolver and serializer and qualify its public workflows before claiming conformance. + ## Lifecycle and events LifecycleContract defines callback ordering, information availability, intent permissions, and diff --git a/src/ml4t/specs/__init__.py b/src/ml4t/specs/__init__.py index 722e55e..fc50178 100644 --- a/src/ml4t/specs/__init__.py +++ b/src/ml4t/specs/__init__.py @@ -82,6 +82,14 @@ MarketDataSpec, TimestampSemantics, ) +from .time import ( + UNSET_CALENDAR, + BarGridAnchor, + BarLabel, + CalendarUnset, + PartialBarPolicy, + WindowUnit, +) __version__ = "0.1.5" @@ -92,9 +100,12 @@ "ArtifactStorage", "AssetTarget", "BarPathPolicy", + "BarGridAnchor", + "BarLabel", "BarPayload", "CallbackCardinality", "CallbackExceptionSemantics", + "CalendarUnset", "CanonicalChildOrderIntent", "CanonicalTargetIntent", "EvaluationMode", @@ -126,6 +137,7 @@ "OrderParameters", "OrderSide", "OrderType", + "PartialBarPolicy", "PositionActionType", "PositionRuleDefinition", "PositionRulePolicy", @@ -144,6 +156,8 @@ "TimestampSemantics", "TradePayload", "UnsupportedLifecycleVersionError", + "UNSET_CALENDAR", + "WindowUnit", "canonical_intent_fixture", "compare_child_intents", "compare_target_intents", diff --git a/src/ml4t/specs/market_data.py b/src/ml4t/specs/market_data.py index 8b5a2d9..85bde58 100644 --- a/src/ml4t/specs/market_data.py +++ b/src/ml4t/specs/market_data.py @@ -3,11 +3,28 @@ from __future__ import annotations from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass, field, replace +from dataclasses import asdict, dataclass, field, replace from enum import StrEnum from typing import Any, ClassVar -from .base import ArtifactKind, ArtifactProvenance, ArtifactSpec, ArtifactStorage, optional_str +from .base import ( + ArtifactKind, + ArtifactProvenance, + ArtifactSpec, + ArtifactStorage, + optional_str, + serialize_artifact_value, +) +from .time import ( + UNSET_CALENDAR, + BarGridAnchor, + BarLabel, + CalendarUnset, + PartialBarPolicy, + _normalize_time_contract, + _resolve_calendar, + _serialize_time_contract, +) class TimestampSemantics(StrEnum): @@ -44,6 +61,10 @@ class FeedSpec: "bar_type": ("bar_type",), "timestamp_semantics": ("timestamp_semantics",), "session_start_time": ("session_start_time",), + "time_contract_version": ("time_contract_version",), + "bar_grid_anchor": ("bar_grid_anchor",), + "bar_label": ("bar_label",), + "partial_bar_policy": ("partial_bar_policy",), } _SEMANTICS_FIELDS: ClassVar[frozenset[str]] = frozenset( { @@ -53,6 +74,10 @@ class FeedSpec: "bar_type", "timestamp_semantics", "session_start_time", + "time_contract_version", + "bar_grid_anchor", + "bar_label", + "partial_bar_policy", } ) @@ -74,19 +99,32 @@ class FeedSpec: mid_col: str | None = None bid_size_col: str | None = None ask_size_col: str | None = None - calendar: str | None = None + calendar: str | None | CalendarUnset = UNSET_CALENDAR timezone: str | None = None data_frequency: Any | None = None bar_type: str | None = None timestamp_semantics: TimestampSemantics | str | None = None session_start_time: str | None = None + time_contract_version: int = field(default=1, kw_only=True) + bar_grid_anchor: BarGridAnchor | str | None = field(default=None, kw_only=True) + bar_label: BarLabel | str | None = field(default=None, kw_only=True) + partial_bar_policy: PartialBarPolicy | str | None = field(default=None, kw_only=True) def __post_init__(self) -> None: semantics = self.timestamp_semantics - if semantics is None: - return - if not isinstance(semantics, TimestampSemantics): + if semantics is not None and not isinstance(semantics, TimestampSemantics): object.__setattr__(self, "timestamp_semantics", TimestampSemantics(str(semantics))) + _normalize_time_contract(self) + + def resolve_calendar( + self, *, calendar: str | None | CalendarUnset = UNSET_CALENDAR + ) -> str | None: + """Resolve explicit selection, declared metadata, then NYSE, preserving None.""" + return _resolve_calendar(self.calendar, self.time_contract_version, calendar) + + def to_dict(self) -> dict[str, Any]: + """Serialize temporal declaration presence without leaking the unset marker.""" + return serialize_artifact_value(_serialize_time_contract(asdict(self))) @classmethod def from_mapping(cls, mapping: Mapping[str, Any]) -> FeedSpec: @@ -144,8 +182,15 @@ def from_any(cls, value: Any | None) -> FeedSpec: return cls.from_object(value) def with_overrides(self, **overrides: Any) -> FeedSpec: - """Return a new spec with explicit non-null overrides applied.""" - updates = {key: value for key, value in overrides.items() if value is not None} + """Apply overrides; version 2 also accepts explicit calendar=None to disable it.""" + version = overrides.get("time_contract_version") + if version is None: + version = self.time_contract_version + updates = { + key: value + for key, value in overrides.items() + if value is not None or (key == "calendar" and version == 2) + } return replace(self, **updates) if updates else self def resolve(self, columns: Sequence[str], entity_candidates: Sequence[str]) -> FeedSpec: @@ -309,17 +354,31 @@ class MarketDataSemantics: """Temporal and execution semantics for tradable market data.""" data_frequency: str | None = None - calendar: str | None = None + calendar: str | None | CalendarUnset = UNSET_CALENDAR timezone: str | None = None timestamp_semantics: TimestampSemantics | str | None = None session_start_time: str | None = None bar_type: str | None = None + time_contract_version: int = field(default=1, kw_only=True) + bar_grid_anchor: BarGridAnchor | str | None = field(default=None, kw_only=True) + bar_label: BarLabel | str | None = field(default=None, kw_only=True) + partial_bar_policy: PartialBarPolicy | str | None = field(default=None, kw_only=True) def __post_init__(self) -> None: semantics = self.timestamp_semantics - if semantics is None or isinstance(semantics, TimestampSemantics): - return - object.__setattr__(self, "timestamp_semantics", TimestampSemantics(str(semantics))) + if semantics is not None and not isinstance(semantics, TimestampSemantics): + object.__setattr__(self, "timestamp_semantics", TimestampSemantics(str(semantics))) + _normalize_time_contract(self) + + def resolve_calendar( + self, *, calendar: str | None | CalendarUnset = UNSET_CALENDAR + ) -> str | None: + """Resolve explicit selection, declared metadata, then NYSE, preserving None.""" + return _resolve_calendar(self.calendar, self.time_contract_version, calendar) + + def to_dict(self) -> dict[str, Any]: + """Serialize temporal declaration presence without leaking the unset marker.""" + return serialize_artifact_value(_serialize_time_contract(asdict(self))) @classmethod def from_mapping(cls, mapping: Mapping[str, Any] | None) -> MarketDataSemantics: @@ -329,11 +388,19 @@ def from_mapping(cls, mapping: Mapping[str, Any] | None) -> MarketDataSemantics: raise TypeError("semantics must be a mapping or None") return cls( data_frequency=optional_str(mapping.get("data_frequency")), - calendar=optional_str(mapping.get("calendar")), + calendar=( + mapping.get("calendar", UNSET_CALENDAR) + if mapping.get("time_contract_version", 1) != 1 + else optional_str(mapping.get("calendar")) + ), timezone=optional_str(mapping.get("timezone")), timestamp_semantics=mapping.get("timestamp_semantics"), session_start_time=optional_str(mapping.get("session_start_time")), bar_type=optional_str(mapping.get("bar_type")), + time_contract_version=mapping.get("time_contract_version", 1), + bar_grid_anchor=mapping.get("bar_grid_anchor"), + bar_label=mapping.get("bar_label"), + partial_bar_policy=mapping.get("partial_bar_policy"), ) @@ -345,6 +412,12 @@ class MarketDataSpec(ArtifactSpec): schema: MarketDataSchema = field(default_factory=MarketDataSchema) semantics: MarketDataSemantics = field(default_factory=MarketDataSemantics) + def to_dict(self) -> dict[str, Any]: + """Serialize the artifact with its temporal contract's omission semantics.""" + payload = ArtifactSpec.to_dict(self) + payload["semantics"] = self.semantics.to_dict() + return payload + @classmethod def from_mapping(cls, mapping: Mapping[str, Any]) -> MarketDataSpec: if not isinstance(mapping, Mapping): diff --git a/src/ml4t/specs/time.py b/src/ml4t/specs/time.py new file mode 100644 index 0000000..2d7b7f4 --- /dev/null +++ b/src/ml4t/specs/time.py @@ -0,0 +1,109 @@ +"""Runtime-neutral temporal declarations; schedule execution belongs to consumers.""" + +from __future__ import annotations + +from enum import Enum, StrEnum +from typing import Any + + +class CalendarUnset(Enum): + """Distinguish an omitted calendar argument from an explicit no-calendar request.""" + + UNSET = "unset" + + +UNSET_CALENDAR = CalendarUnset.UNSET + + +class WindowUnit(StrEnum): + """Units consumers must not implicitly interchange when interpreting a window.""" + + OBSERVATION = "observation" + EXPECTED_BAR = "expected_bar" + SESSION = "session" + ELAPSED = "elapsed" + + +class BarGridAnchor(StrEnum): + """Origin of a fixed bar grid, restarted at each tradable interval's open.""" + + SESSION_OPEN = "session_open" + + +class BarLabel(StrEnum): + """Boundary represented by a fixed bar's timestamp.""" + + LEFT = "left" + RIGHT = "right" + + +class PartialBarPolicy(StrEnum): + """Treatment of the final interval shorter than the declared bar duration.""" + + INCLUDE = "include" + EXCLUDE = "exclude" + + +_GRID_FIELDS = { + "bar_grid_anchor": BarGridAnchor, + "bar_label": BarLabel, + "partial_bar_policy": PartialBarPolicy, +} + + +def _validate_calendar(calendar: Any) -> str | None: + if calendar is None or isinstance(calendar, str) and calendar.strip(): + return calendar + raise ValueError("calendar must be a non-empty name or None") + + +def _normalize_time_contract(contract: Any) -> None: + version = contract.time_contract_version + if isinstance(version, bool) or version not in (1, 2) or not isinstance(version, int): + raise ValueError("time_contract_version must be 1 or 2") + calendar = contract.calendar + if version == 1: + if any(getattr(contract, name) is not None for name in _GRID_FIELDS): + raise ValueError("bar grid declarations require time_contract_version=2") + if calendar is UNSET_CALENDAR: + object.__setattr__(contract, "calendar", None) + return + + if calendar is not UNSET_CALENDAR: + _validate_calendar(calendar) + normalized = {} + for name, enum in _GRID_FIELDS.items(): + value = getattr(contract, name) + if value is not None: + try: + normalized[name] = enum(value) + except (ValueError, TypeError) as exc: + raise ValueError(f"invalid {name}: {value!r}") from exc + if contract.timestamp_semantics == "bar_close" and contract.bar_label == "left": + raise ValueError("bar_close timestamp_semantics requires bar_label='right'") + if contract.timestamp_semantics == "session_label" and normalized: + raise ValueError("session_label timestamps cannot declare an intraday bar grid") + for name, value in normalized.items(): + object.__setattr__(contract, name, value) + + +def _resolve_calendar( + declaration: str | None | CalendarUnset, + version: int, + calendar: str | None | CalendarUnset, +) -> str | None: + if calendar is not UNSET_CALENDAR: + return _validate_calendar(calendar) + if declaration is UNSET_CALENDAR or (version == 1 and declaration is None): + return "NYSE" + return _validate_calendar(declaration) + + +def _serialize_time_contract(data: dict[str, Any]) -> dict[str, Any]: + if data["time_contract_version"] == 1: + del data["time_contract_version"] + for name in _GRID_FIELDS: + del data[name] + elif data["calendar"] is UNSET_CALENDAR: + del data["calendar"] + return data diff --git a/tests/test_time_contract.py b/tests/test_time_contract.py index 64fc2d2..2d5c830 100644 --- a/tests/test_time_contract.py +++ b/tests/test_time_contract.py @@ -77,7 +77,9 @@ def test_grid_declarations_survive_real_serialized_feed_handoff(tmp_path): "bar_label": "right", "partial_bar_policy": "include", } - artifact = MarketDataSpec(artifact_id="hourly", semantics=MarketDataSemantics(**declaration)) + artifact = MarketDataSpec( + artifact_id="hourly", semantics=MarketDataSemantics.from_mapping(declaration) + ) path = tmp_path / "hourly.json" write_spec_payload(artifact, path) feed = FeedSpec.from_mapping(read_spec_payload(path)) @@ -141,3 +143,77 @@ def test_window_units_do_not_alias_rows_sessions_and_elapsed_time(): } with pytest.raises(ValueError): WindowUnit("day") + + +@pytest.mark.parametrize("contract_type", [FeedSpec, MarketDataSemantics]) +def test_grid_declaration_requires_versioned_opt_in(contract_type): + with pytest.raises(ValueError, match="time_contract_version=2"): + contract_type(bar_label="right") + + +@pytest.mark.parametrize("calendar, expected", [(None, None), ("24/7", "24/7")]) +def test_semantics_calendar_resolution_matches_serialized_feed(calendar, expected): + semantics = MarketDataSemantics(time_contract_version=2, calendar=calendar) + assert semantics.resolve_calendar() == expected + feed = MarketDataSpec(artifact_id="bars", semantics=semantics).to_feed_spec() + assert feed.resolve_calendar() == semantics.resolve_calendar() + assert semantics.resolve_calendar(calendar="NYSE") == "NYSE" + with pytest.raises(ValueError, match="calendar"): + semantics.resolve_calendar(calendar="") + + +def test_ignored_version_override_does_not_discard_calendar_disable(): + feed = FeedSpec(time_contract_version=2, calendar="NYSE") + assert feed.with_overrides(time_contract_version=None, calendar=None).resolve_calendar() is None + + +def test_reset_to_inherited_calendar_survives_serialization(): + from ml4t.specs import UNSET_CALENDAR + + disabled = FeedSpec(time_contract_version=2, calendar=None) + inherited = disabled.with_overrides(calendar=UNSET_CALENDAR) + assert "calendar" not in inherited.to_dict() + assert FeedSpec.from_mapping(inherited.to_dict()).resolve_calendar() == "NYSE" + + +def test_invalid_grid_does_not_replace_an_existing_persisted_contract(tmp_path): + path = tmp_path / "bars.json" + write_spec_payload(MarketDataSpec(artifact_id="existing"), path) + before = path.read_bytes() + payload = { + "artifact_id": "new", + "semantics": { + "time_contract_version": 2, + "timestamp_semantics": "bar_close", + "bar_label": "left", + }, + } + with pytest.raises(ValueError, match="bar_close"): + write_spec_payload(MarketDataSpec.from_mapping(payload), path) + assert path.read_bytes() == before + + +def test_left_labeled_complete_bar_policy_is_preserved(tmp_path): + spec = FeedSpec( + time_contract_version=2, + calendar="24/7", + data_frequency="1h", + bar_grid_anchor="session_open", + bar_label="left", + partial_bar_policy="exclude", + ) + path = tmp_path / "feed.yaml" + write_spec_payload(spec.to_dict(), path) + restored = FeedSpec.from_mapping(read_spec_payload(path)) + assert restored.bar_label == "left" + assert restored.partial_bar_policy == "exclude" + assert restored.bar_grid_anchor == "session_open" + + +def test_version_override_migrates_legacy_declaration_without_losing_explicit_null(): + legacy = FeedSpec(calendar="NYSE") + migrated = legacy.with_overrides(time_contract_version=2, calendar=None) + assert migrated.time_contract_version == 2 + assert migrated.resolve_calendar() is None + assert FeedSpec.from_mapping(migrated.to_dict()).resolve_calendar() is None + assert legacy.resolve_calendar() == "NYSE"