diff --git a/src/research_signal_context_pipelines/overlay_backtest.py b/src/research_signal_context_pipelines/overlay_backtest.py index 9cb273e..65a56ee 100644 --- a/src/research_signal_context_pipelines/overlay_backtest.py +++ b/src/research_signal_context_pipelines/overlay_backtest.py @@ -53,8 +53,10 @@ def decision_datetime_for_date(date: dt.date) -> dt.datetime: def signal_available_at(signal: dict[str, Any]) -> dt.datetime: - raw = signal.get("available_at", signal["generated_at"]) - return parse_datetime(str(raw)) + generated = parse_datetime(str(signal["generated_at"])) + if "available_at" not in signal: + return generated + return max(generated, parse_datetime(str(signal["available_at"]))) def load_price_history(path: Path, *, symbol: str) -> list[PricePoint]: diff --git a/src/research_signal_context_pipelines/schema.py b/src/research_signal_context_pipelines/schema.py index dbad249..0119d1b 100644 --- a/src/research_signal_context_pipelines/schema.py +++ b/src/research_signal_context_pipelines/schema.py @@ -64,6 +64,15 @@ def _require_iso_datetime(value: Any, name: str) -> str: return text +def _parse_iso_datetime_utc(value: Any, name: str) -> dt.datetime: + text = _require_iso_datetime(value, name) + normalized = text[:-1] + "+00:00" if text.endswith("Z") else text + parsed = dt.datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc) + + def _require_string_list(value: Any, name: str, *, allow_empty: bool = False) -> list[str]: if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): raise SignalValidationError(f"{name} must be a list of strings") @@ -91,8 +100,12 @@ def validate_signal(payload: Mapping[str, Any]) -> None: if schema_version not in SUPPORTED_SCHEMA_VERSIONS: raise SignalValidationError("schema_version must be '1' or '2'") _require_iso_date(payload["as_of"], "as_of") - _require_iso_datetime(payload["generated_at"], "generated_at") + generated_at = _parse_iso_datetime_utc(payload["generated_at"], "generated_at") _require_iso_date(payload["expires_at"], "expires_at") + if "available_at" in payload: + available_at = _parse_iso_datetime_utc(payload["available_at"], "available_at") + if available_at < generated_at: + raise SignalValidationError("available_at must be >= generated_at") if schema_version == "2": _require_string(payload.get("model_version"), "model_version") _require_string(payload.get("scoring_version"), "scoring_version") diff --git a/tests/test_overlay_backtest.py b/tests/test_overlay_backtest.py index c996f0a..cd227df 100644 --- a/tests/test_overlay_backtest.py +++ b/tests/test_overlay_backtest.py @@ -5,6 +5,8 @@ import json from pathlib import Path +import pytest + from research_signal_context_pipelines.overlay_backtest import ( OverlayPolicy, PricePoint, @@ -13,8 +15,10 @@ load_price_history, load_signals, signal_active_on, + signal_available_at, signal_for_date, ) +from research_signal_context_pipelines.schema import SignalValidationError, validate_signal ROOT = Path(__file__).resolve().parents[1] @@ -72,6 +76,46 @@ def test_signal_active_on_uses_available_at_when_present() -> None: assert signal_for_date([signal], dt.date(2026, 2, 9)) is not None +def test_signal_available_at_clamps_early_available_at_to_generated_at() -> None: + signal = _example_signal( + generated_at="2026-02-20T22:00:00Z", + available_at="2026-02-06T00:00:00Z", + ) + + assert signal_available_at(signal) == dt.datetime(2026, 2, 20, 22, 0, tzinfo=dt.timezone.utc) + + +def test_early_available_at_does_not_rewrite_earlier_overlay_path() -> None: + prices = [ + PricePoint(date=dt.date(2026, 2, 6), close=100.0), + PricePoint(date=dt.date(2026, 2, 13), close=90.0), + PricePoint(date=dt.date(2026, 2, 20), close=80.0), + PricePoint(date=dt.date(2026, 2, 27), close=70.0), + ] + late = _example_signal( + as_of="2026-02-06", + generated_at="2026-02-20T22:00:00Z", + expires_at="2026-03-19", + regime="risk_off", + confidence=0.9, + risk_flags=["liquidity_stress"], + ) + early_available = copy.deepcopy(late) + early_available["available_at"] = "2026-02-06T00:00:00Z" + + without_signal = backtest_overlay(prices, []) + with_late = backtest_overlay(prices, [late]) + with_early = backtest_overlay(prices, [early_available]) + + # Defense in depth: early available_at must not rewrite pre-generation history. + assert with_early["overlay"]["final_equity"] == with_late["overlay"]["final_equity"] + assert with_early["overlay"]["final_equity"] == without_signal["overlay"]["final_equity"] + assert with_early["overlay"]["avg_exposure"] == without_signal["overlay"]["avg_exposure"] + + with pytest.raises(SignalValidationError, match="available_at must be >= generated_at"): + validate_signal(early_available) + + def test_future_generated_at_does_not_rewrite_earlier_overlay_path() -> None: prices = [ PricePoint(date=dt.date(2026, 2, 6), close=100.0), diff --git a/tests/test_signal_validation.py b/tests/test_signal_validation.py index 001b075..35d5c6b 100644 --- a/tests/test_signal_validation.py +++ b/tests/test_signal_validation.py @@ -231,3 +231,35 @@ def test_confidence_rejects_nan() -> None: with pytest.raises(SignalValidationError, match="finite|between 0 and 1"): validate_signal(payload) + + +def test_missing_available_at_is_allowed() -> None: + payload = load_example() + payload.pop("available_at", None) + + validate_signal(payload) + + +def test_available_at_after_generated_at_is_allowed() -> None: + payload = load_example() + payload["generated_at"] = "2026-05-28T12:00:00Z" + payload["available_at"] = "2026-05-28T22:00:00Z" + + validate_signal(payload) + + +def test_available_at_before_generated_at_is_rejected() -> None: + payload = load_example() + payload["generated_at"] = "2026-05-28T22:00:00Z" + payload["available_at"] = "2026-05-28T12:00:00Z" + + with pytest.raises(SignalValidationError, match="available_at must be >= generated_at"): + validate_signal(payload) + + +def test_available_at_present_but_invalid_is_rejected() -> None: + payload = load_example() + payload["available_at"] = "" + + with pytest.raises(SignalValidationError, match="available_at"): + validate_signal(payload)