diff --git a/docs/advisory_contract.md b/docs/advisory_contract.md index 9f43fe1..e936c33 100644 --- a/docs/advisory_contract.md +++ b/docs/advisory_contract.md @@ -28,6 +28,20 @@ policy: object The current CLI and scheduled workflows produce v6. Readers continue to accept legacy v5 reports, but no v6 field is silently accepted on a v5 report. +An explicit `as_of` is a UTC calendar date, not the invocation's local date. +Its entire UTC day must have ended before a report can be generated: +`generated_at >= reference_time`. For example, `as_of=2026-10-15` first becomes +valid at `2026-10-16T00:00:00Z` (`2026-10-16T08:00:00+08:00`). An earlier build +is rejected with the requested date, required UTC cutoff, and actual generation +time. Offsets are normalized to UTC before comparison; equality is accepted. + +Leave the workflow's `as_of` input empty to select a fully closed period: +yesterday for daily reports, the latest completed Saturday for weekly reports, +or the last day of the previous month for monthly reports, all in UTC. A manual +override keeps its requested date and is rejected if that date has not closed; +the producer does not shift the cutoff or change generation timestamps to make +an early request pass. + ### V6 default contract A v6 report uses `contract_version = model_recommendations.v6` and adds these diff --git a/src/quant_advisor_research/time_contract.py b/src/quant_advisor_research/time_contract.py index 1211751..f8023fa 100644 --- a/src/quant_advisor_research/time_contract.py +++ b/src/quant_advisor_research/time_contract.py @@ -49,7 +49,10 @@ def report_time_bounds(as_of: dt.date, generated_at: dt.datetime | str) -> Repor reference_time = canonical_reference_time(as_of) generated = normalize_aware_datetime(generated_at) if generated < reference_time: - raise TimeContractError("generated_at must be at or after reference_time") + raise TimeContractError( + f"generated_at must be at or after reference_time: as_of={as_of.isoformat()} " + f"requires generated_at >= {reference_time.isoformat()}; got {generated.isoformat()}" + ) return ReportTimeBounds(reference_time, generated, generated + dt.timedelta(days=REPORT_EXPIRY_DAYS)) diff --git a/tests/test_time_contract.py b/tests/test_time_contract.py index f608e17..af36fe2 100644 --- a/tests/test_time_contract.py +++ b/tests/test_time_contract.py @@ -35,6 +35,30 @@ def test_reference_and_build_order_are_strict() -> None: report_time_bounds(dt.date(2026, 5, 30), "2026-05-30T12:00:00Z") +@pytest.mark.parametrize( + "generated_at", + ["2026-10-15T23:59:59.999999Z", "2026-10-16T07:59:59.999999+08:00"], +) +def test_unclosed_as_of_reports_the_required_utc_cutoff(generated_at: str) -> None: + with pytest.raises(TimeContractError) as exc: + report_time_bounds(dt.date(2026, 10, 15), generated_at) + + assert "as_of=2026-10-15" in str(exc.value) + assert "requires generated_at >= 2026-10-16T00:00:00+00:00" in str(exc.value) + assert "got 2026-10-15T23:59:59.999999+00:00" in str(exc.value) + + +@pytest.mark.parametrize( + "generated_at", + ["2026-10-16T00:00:00Z", "2026-10-16T08:00:00+08:00", "2026-10-15T20:00:00-04:00"], +) +def test_closed_as_of_accepts_the_exact_cutoff_in_any_offset(generated_at: str) -> None: + bounds = report_time_bounds(dt.date(2026, 10, 15), generated_at) + + assert bounds.reference_time == dt.datetime(2026, 10, 16, tzinfo=dt.UTC) + assert bounds.generated_at == bounds.reference_time + + def test_context_date_only_expiry_uses_original_offset_local_eod() -> None: assert context_expiry_instant("2026-06-30", "2026-05-30T00:30:00+08:00") == dt.datetime( 2026, 6, 30, 15, 59, 59, tzinfo=dt.UTC