diff --git a/README.md b/README.md index 9ca3e1b..8823acd 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,13 @@ platform marks unified signals as required, failure to resolve a valid artifact should fail fast; only validated `last_valid` artifacts inside the configured stale window are an acceptable fallback. +The scheduled IBIT BTC daily handoff uses a 48-hour freshness limit because +the UTC daily candle carries its opening timestamp while the completed candle +is published the next day and consumed during the US trading afternoon. This +limit still rejects a missed daily publication before the following US session; +other bundle builders retain their 36-hour default. Do not advance the source +timestamp to the publish time or reuse an older candle to pass this check. + Platform CI can then validate one handoff manifest before loading the linked bundle: diff --git a/scripts/publish_ibit_btc_platform_handoff.py b/scripts/publish_ibit_btc_platform_handoff.py index ce3d594..9fc4211 100644 --- a/scripts/publish_ibit_btc_platform_handoff.py +++ b/scripts/publish_ibit_btc_platform_handoff.py @@ -18,6 +18,9 @@ DEFAULT_CONSUMER = "us_equity:ibit_smart_dca" DEFAULT_STRATEGY = "ibit_smart_dca" DEFAULT_GCS_PREFIX = "gs://qsl-runtime-logs-shared/platform_handoffs" +# BTC UTC daily bars are timestamped at their opening midnight. The next +# completed bar is published before the following US trading afternoon. +IBIT_BTC_DAILY_MAX_AGE_HOURS = 48 def default_as_of(*, today: date | None = None) -> str: @@ -136,6 +139,8 @@ def build_ibit_btc_platform_handoff( code_commit, "--generated-at", generated_at, + "--max-age-hours", + str(IBIT_BTC_DAILY_MAX_AGE_HOURS), ] ) if build_exit != 0: diff --git a/src/market_signal_sources/cli/build_btc_cycle_bundle.py b/src/market_signal_sources/cli/build_btc_cycle_bundle.py index e9e9450..c466872 100644 --- a/src/market_signal_sources/cli/build_btc_cycle_bundle.py +++ b/src/market_signal_sources/cli/build_btc_cycle_bundle.py @@ -50,6 +50,7 @@ def main(argv: Sequence[str] | None = None) -> int: generated_at=args.generated_at, provider_timestamp=provider_metadata.provider_timestamp, freshness_status=args.freshness_status, + max_age_hours=args.max_age_hours, license_scope=provider_metadata.license_scope, generated_by=provider_metadata.generated_by, ) @@ -117,6 +118,7 @@ def _build_parser() -> argparse.ArgumentParser: ) parser.add_argument("--generated-at", required=True) parser.add_argument("--freshness-status", default="fresh") + parser.add_argument("--max-age-hours", type=int, default=36) parser.add_argument("--date-column", default="date") parser.add_argument("--close-column", default="close") parser.add_argument("--high-column", default="high") diff --git a/tests/test_publish_ibit_btc_platform_handoff.py b/tests/test_publish_ibit_btc_platform_handoff.py index fb7112c..ae1cd39 100644 --- a/tests/test_publish_ibit_btc_platform_handoff.py +++ b/tests/test_publish_ibit_btc_platform_handoff.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import date from pathlib import Path @@ -9,9 +10,14 @@ from scripts.publish_ibit_btc_platform_handoff import ( BINANCE_BTCUSDT_DAILY_URLS, _provider_name_for_url, + build_ibit_btc_platform_handoff, default_as_of, resolve_as_of, ) +from market_signal_sources.artifacts.validation import ( + SignalBundleValidationError, + validate_signal_bundle, +) def _write_btc_csv(path: Path, dates: list[str]) -> None: @@ -62,3 +68,24 @@ def test_provider_name_for_url() -> None: assert _provider_name_for_url(BINANCE_BTCUSDT_DAILY_URLS[0]) == "binance_vision_public" assert _provider_name_for_url(BINANCE_BTCUSDT_DAILY_URLS[1]) == "binance_us_public" assert _provider_name_for_url(BINANCE_BTCUSDT_DAILY_URLS[2]) == "binance_public" + + +def test_ibit_daily_handoff_remains_fresh_through_next_us_trading_afternoon(tmp_path: Path) -> None: + csv_path = tmp_path / "btc.csv" + dates = pd.date_range(end="2026-09-24", periods=800, freq="D") + _write_btc_csv(csv_path, [day.date().isoformat() for day in dates]) + artifacts = build_ibit_btc_platform_handoff( + work_dir=tmp_path / "output", + input_csv=csv_path, + as_of="2026-09-24", + code_commit="a" * 40, + source_version="0.1.1", + ) + bundle = json.loads( + (artifacts["publication_dir"] / "bundle" / "signal_bundle.json").read_text(encoding="utf-8") + ) + assert bundle["freshness"]["provider_timestamp"] == "2026-09-24T00:00:00Z" + assert bundle["freshness"]["max_age_hours"] == 48 + validate_signal_bundle(bundle, now="2026-09-25T19:45:00Z") + with pytest.raises(SignalBundleValidationError, match="freshness"): + validate_signal_bundle(bundle, now="2026-09-26T00:00:01Z")