Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/research_signal_context_pipelines/overlay_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
15 changes: 14 additions & 1 deletion src/research_signal_context_pipelines/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
44 changes: 44 additions & 0 deletions tests/test_overlay_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import json
from pathlib import Path

import pytest

from research_signal_context_pipelines.overlay_backtest import (
OverlayPolicy,
PricePoint,
Expand All @@ -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]
Expand Down Expand Up @@ -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),
Expand Down
32 changes: 32 additions & 0 deletions tests/test_signal_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)