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
14 changes: 14 additions & 0 deletions docs/advisory_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/quant_advisor_research/time_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))


Expand Down
24 changes: 24 additions & 0 deletions tests/test_time_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading