diff --git a/Dockerfile b/Dockerfile index da6dd2f8..b9bbf758 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,8 @@ RUN apt-get update \ COPY . . RUN python -m pip install --upgrade pip uv \ - && uv sync --frozen --no-dev + && uv sync --frozen --no-dev \ + && uv run --no-sync python -m scripts.check_ues_revision # Keep the application timeout below Cloud Run/Scheduler's 600-second ceiling so # Gunicorn can return a controlled failure instead of being killed at the edge. diff --git a/application/v7_paper_application.py b/application/v7_paper_application.py index 847fa621..b68240d0 100644 --- a/application/v7_paper_application.py +++ b/application/v7_paper_application.py @@ -33,7 +33,9 @@ # The research contract's frozen source is 07b164..., while the package that # is actually approved for the disabled account process tracks the platform UES pin. # Keep research source_commit separate from approved_ues_revision. -V7_APPROVED_UES_REVISION = "8412d8338879f335ef8830678c2733e02e53e7c7" +# UES #715 fixes the Russell Top50 cash_reserve_ratio TypeError. +# Keep the controlled PAPER revision equal to the reviewed dependency pin. +V7_APPROVED_UES_REVISION = "c16c81bdf21824615f7280554559adc6c721917c" _COMMIT_PATTERN = re.compile(r"^[0-9a-fA-F]{40}$") _TICKET_PATTERN = re.compile(r"^rpt_[0-9a-fA-F]{64}$") diff --git a/docs/paper-strategy-risk-state.md b/docs/paper-strategy-risk-state.md index 99b08181..6fd20a99 100644 --- a/docs/paper-strategy-risk-state.md +++ b/docs/paper-strategy-risk-state.md @@ -20,3 +20,26 @@ does not change allocations or authorize a paper/shadow/live command. No existing LongBridge strategy emits this transition yet. Enabling the flag before a qualified strategy supplies a frozen transition will stop the cycle with an operator-visible error rather than silently forgetting state. + +## Russell regime binding + +The pinned UES Russell entrypoint requires a separately configured +`market_regime_control_expected` binding, even when position-control route +application is disabled. LongBridge reads the binding and the +`market_regime_control` signal from the existing `strategy_config_path` JSON +object on every feature-snapshot evaluation. The binding is passed as runtime +configuration; the signal is passed as a context artifact, not as a ranking +parameter. Other profiles keep their existing configuration path. + +The platform never derives an expected digest from the supplied signal. The +configuration producer must provide the independent binding fields `plugin`, +`schema_version`, `as_of`, `valid_until` and `payload_sha256`. UES validates the +plugin, schema, signal date, evaluation date range and canonical JSON SHA-256 +against the exact signal. Missing or invalid bindings and missing signals keep +ordinary decisions non-actionable; unreadable or malformed configuration stops +evaluation with a fixed diagnostic. Restarting a process does not grant an +exception, and the next evaluation re-reads configuration after revocation. + +This consumer integration does not publish a signal, supply approval, enable a +runtime target or qualify a PAPER session. Authentic signal and independent +binding provisioning remain prerequisites for deployment under owner approval. diff --git a/pyproject.toml b/pyproject.toml index 44b3876f..5632359b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "google-auth", "longport==3.0.23", "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@350dd38ece0952beef096893a7dcf8ae3871609a", - "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@8412d8338879f335ef8830678c2733e02e53e7c7", + "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@c16c81bdf21824615f7280554559adc6c721917c", "hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@fe0c32e09d454ca4388c3372bf3874097b1bbaf0", ] diff --git a/scripts/check_ues_revision.py b/scripts/check_ues_revision.py new file mode 100644 index 00000000..84d8ecc1 --- /dev/null +++ b/scripts/check_ues_revision.py @@ -0,0 +1,41 @@ +"""Fail the image build unless installed UES matches the frozen source pin.""" +from __future__ import annotations + +from importlib import metadata +import json +from pathlib import Path +import sys + +from scripts.review_shared_dependency_delta import Incomplete, resolve_pins + +ROOT = Path(__file__).resolve().parents[1] + + +def verify(root: Path) -> None: + try: + pins = resolve_pins( + (root / "pyproject.toml").read_text(encoding="utf-8"), + (root / "uv.lock").read_text(encoding="utf-8"), + ) + raw = metadata.distribution("us-equity-strategies").read_text("direct_url.json") + installed = json.loads(raw or "{}") + vcs = installed.get("vcs_info", {}) + if vcs.get("vcs") != "git" or vcs.get("commit_id") != pins["us-equity-strategies"]: + raise Incomplete("Installed UES does not match the frozen source pin") + except (OSError, ValueError, TypeError, AttributeError, metadata.PackageNotFoundError) as exc: + raise Incomplete("UES revision evidence unavailable or invalid") from exc + + +def main() -> int: + try: + verify(ROOT) + except Incomplete: + # Metadata, file paths and exception diagnostics may contain private data. + print("UES installed revision verification failed.", file=sys.stderr) + return 1 + print("UES installed revision verified against pyproject.toml and uv.lock.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/strategy_runtime.py b/strategy_runtime.py index 98920e9b..bd10f092 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -1,8 +1,10 @@ from __future__ import annotations import os -from dataclasses import dataclass, field +import json +from dataclasses import dataclass, field, replace from datetime import datetime, timezone +from pathlib import Path from typing import Any, Callable, Mapping from quant_platform_kit.common.feature_snapshot import load_feature_snapshot_guarded @@ -303,7 +305,7 @@ def _build_runtime_risk_capabilities( return capability_payload, "verified:runtime_risk_limits" def _build_feature_snapshot_context(self, request): - return build_strategy_context_from_available_inputs( + context = build_strategy_context_from_available_inputs( entrypoint=request.entrypoint, runtime_adapter=request.runtime_adapter, as_of=request.as_of, @@ -311,6 +313,23 @@ def _build_feature_snapshot_context(self, request): runtime_config=request.runtime_config, capabilities=self._build_capital_base_capabilities(request.available_inputs), ) + if self.profile != "russell_top50_leader_rotation" or not self.runtime_settings.strategy_config_path: + return context + # The platform configuration supplies an independent expected binding. + # Never derive it from the signal or cache it across evaluations. + try: + config = json.loads(Path(self.runtime_settings.strategy_config_path).read_text(encoding="utf-8")) + if not isinstance(config, Mapping): + raise ValueError + except (OSError, ValueError, TypeError): + raise ValueError("Invalid Russell regime configuration") from None + runtime_config = dict(context.runtime_config) + if "market_regime_control_expected" in config: + runtime_config["market_regime_control_expected"] = config["market_regime_control_expected"] + artifacts = dict(context.artifacts) + if "market_regime_control" in config: + artifacts["market_regime_control"] = config["market_regime_control"] + return replace(context, runtime_config=runtime_config, artifacts=artifacts) def evaluate( self, diff --git a/tests/test_russell_regime_binding.py b/tests/test_russell_regime_binding.py new file mode 100644 index 00000000..1fda3363 --- /dev/null +++ b/tests/test_russell_regime_binding.py @@ -0,0 +1,129 @@ +"""Exercise the pinned UES entrypoint with synthetic platform configuration.""" +import hashlib +import json +from dataclasses import replace +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import patch + +import pandas as pd +import pytest +from quant_platform_kit.common.models import PortfolioSnapshot +from quant_platform_kit.risk.contracts import RuntimeRiskLimits + +import strategy_runtime as runtime_module + +PROFILE = "russell_top50_leader_rotation" +DAY = "2026-11-03" + + +def regime_config(): + payload = { + "plugin": "market_regime_control", + "schema_version": "market_regime_control.v1", + "as_of": DAY, + "canonical_route": "risk_on", + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return { + "market_regime_control": payload, + "market_regime_control_expected": { + "plugin": "market_regime_control", + "schema_version": "market_regime_control.v1", + "as_of": DAY, + "valid_until": DAY, + "payload_sha256": hashlib.sha256(encoded.encode()).hexdigest(), + }, + } + + +@pytest.fixture +def platform(tmp_path): + path = tmp_path / "strategy.json" + path.write_text(json.dumps(regime_config())) + settings = replace(runtime_module._default_runtime_settings(PROFILE, "Synthetic Russell"), + strategy_config_path=str(path), dry_run_only=True, + feature_snapshot_path="synthetic.csv", + feature_snapshot_manifest_path="synthetic-manifest.json") + runtime = runtime_module.load_strategy_runtime( + PROFILE, runtime_settings=settings, logger=lambda _message: None, + execution_materials={"runtime_risk_limits": RuntimeRiskLimits( + allowed_symbols=("FAKEA", "FAKEB", "FAKEC", "FAKED", "BOXX"), + product_leverage_factors={symbol: 1 for symbol in ("FAKEA", "FAKEB", "FAKEC", "FAKED", "BOXX")}, + nominal_caps={symbol: 1.0 for symbol in ("FAKEA", "FAKEB", "FAKEC", "FAKED", "BOXX")}, + total_nominal_exposure_cap=1.0, total_effective_exposure_cap=1.0, max_positions=5, + )}, + ) + # Real ranking/weight construction; only the external snapshot read is replaced. + rows = [dict(symbol=symbol, sector="Synthetic", close=100.0, adv20_usd=100_000_000.0, + history_days=300, mom_3m=0.2, mom_6m=0.3, mom_12_1=0.4, + rel_mom_6m_vs_benchmark=0.1, rel_mom_6m_vs_broad_benchmark=0.1, + high_252_gap=-0.01, sma200_gap=0.1, vol_63=0.15, maxdd_126=-0.1) + for symbol in ("QQQ", "SPY", "FAKEA", "FAKEB", "FAKEC", "FAKED")] + guard = SimpleNamespace(frame=pd.DataFrame(rows), metadata={"snapshot_guard_decision": "proceed"}) + + def evaluate(): + with patch.object(runtime_module, "load_feature_snapshot_guarded", return_value=guard), \ + patch("quant_platform_kit.common.feature_snapshot_runtime.datetime") as clock: + clock.now.return_value = datetime(2026, 11, 3, tzinfo=timezone.utc) + return runtime.evaluate( + translator=lambda key, **_kwargs: key, + portfolio_snapshot=PortfolioSnapshot( + as_of=datetime(2026, 11, 3, tzinfo=timezone.utc), + total_equity=100_000.0, buying_power=100_000.0, positions=(), + ), + ).decision + return path, evaluate + + +def test_ordinary_russell_decision_keeps_target_weights(platform): + _, evaluate = platform + decision = evaluate() + assert "no_execute" not in decision.risk_flags + assert decision.diagnostics["actionable"] is True + assert any(position.symbol.startswith("FAKE") for position in decision.positions) + + +@pytest.mark.parametrize("defect", ["missing", "invalid", "digest", "expired", "missing_signal"]) +def test_russell_binding_gate_stays_closed(platform, defect): + path, evaluate = platform + config = regime_config() + if defect == "missing": + del config["market_regime_control_expected"] + elif defect == "invalid": + config["market_regime_control_expected"] = "invalid" + elif defect == "digest": + config["market_regime_control_expected"]["payload_sha256"] = "0" * 64 + elif defect == "expired": + config["market_regime_control_expected"]["valid_until"] = "2026-11-02" + else: + del config["market_regime_control"] + path.write_text(json.dumps(config)) + decision = evaluate() + assert "no_execute" in decision.risk_flags + assert not decision.positions + assert decision.diagnostics["market_regime_control_binding_valid"] is False + + +def test_next_evaluation_rereads_binding_after_revocation(platform): + path, evaluate = platform + assert "no_execute" not in evaluate().risk_flags + path.write_text(json.dumps({"market_regime_control": regime_config()["market_regime_control"]})) + assert "no_execute" in evaluate().risk_flags + + +@pytest.mark.parametrize("content", ["{", "[]"]) +def test_unreadable_configuration_blocks_without_raw_diagnostics(platform, content): + path, evaluate = platform + path.write_text(content) + with pytest.raises(ValueError, match="^Invalid Russell regime configuration$"): + evaluate() + + +def test_read_failure_blocks_without_private_exception_text(platform): + _, evaluate = platform + with patch("strategy_runtime.Path.read_text", side_effect=OSError("synthetic-private-detail")): + with pytest.raises(ValueError, match="^Invalid Russell regime configuration$") as error: + evaluate() + assert error.value.__suppress_context__ is True + assert "synthetic-private-detail" not in str(error.value) diff --git a/tests/test_ues_revision.py b/tests/test_ues_revision.py new file mode 100644 index 00000000..58035bd4 --- /dev/null +++ b/tests/test_ues_revision.py @@ -0,0 +1,83 @@ +"""Build-time checks use installed VCS metadata, never a declared environment pin.""" +import importlib +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +PIN = "c16c81bdf21824615f7280554559adc6c721917c" +OTHER = "a" * 40 + + +@pytest.fixture +def check(): + return importlib.import_module("scripts.check_ues_revision") + + +@pytest.fixture +def manifests(tmp_path): + for name in ("pyproject.toml", "uv.lock"): + (tmp_path / name).write_bytes((ROOT / name).read_bytes()) + return tmp_path + + +def installed(check, monkeypatch, payload): + monkeypatch.setattr(check.metadata, "distribution", lambda name: SimpleNamespace( + read_text=lambda filename: payload, + )) + + +def test_exact_installed_commit_passes(check, manifests, monkeypatch): + installed(check, monkeypatch, json.dumps({"vcs_info": {"vcs": "git", "commit_id": PIN}})) + assert check.verify(manifests) is None + + +@pytest.mark.parametrize("payload", [ + None, "", "{}", "null", "[]", "not json", + '{"vcs_info": null}', '{"vcs_info": []}', + json.dumps({"vcs_info": {"vcs": "git", "commit_id": OTHER}}), + json.dumps({"vcs_info": {"vcs": "git", "commit_id": PIN[:12]}}), + json.dumps({"vcs_info": {"vcs": "svn", "commit_id": PIN}}), + json.dumps({"vcs_info": {"commit_id": PIN}}), +]) +def test_missing_invalid_or_wrong_metadata_blocks(check, manifests, monkeypatch, payload): + installed(check, monkeypatch, payload) + monkeypatch.setenv("UES_REVISION", PIN) + with pytest.raises(check.Incomplete): + check.verify(manifests) + + +@pytest.mark.parametrize("filename", ["pyproject.toml", "uv.lock"]) +def test_manifest_disagreement_blocks(check, manifests, monkeypatch, filename): + installed(check, monkeypatch, json.dumps({"vcs_info": {"vcs": "git", "commit_id": PIN}})) + path = manifests / filename + path.write_text(path.read_text().replace(PIN, OTHER)) + with pytest.raises(check.Incomplete): + check.verify(manifests) + + +@pytest.mark.parametrize("filename", ["pyproject.toml", "uv.lock"]) +def test_unreadable_manifest_blocks(check, manifests, filename): + (manifests / filename).unlink() + with pytest.raises(check.Incomplete): + check.verify(manifests) + + +@pytest.mark.parametrize("error", [OSError("synthetic-private-detail"), + importlib.metadata.PackageNotFoundError("synthetic-private-detail")]) +def test_distribution_read_failure_blocks_without_diagnostics(check, manifests, monkeypatch, capsys, error): + def fail(_name): + raise error + monkeypatch.setattr(check.metadata, "distribution", fail) + monkeypatch.setattr(check, "ROOT", manifests) + assert check.main() == 1 + output = capsys.readouterr() + assert output.out == "" + assert output.err == "UES installed revision verification failed.\n" + + +def test_docker_build_checks_after_install(): + docker = (ROOT / "Dockerfile").read_text() + assert "uv sync --frozen --no-dev \\\n && uv run --no-sync python -m scripts.check_ues_revision" in docker diff --git a/tests/test_v7_paper_application.py b/tests/test_v7_paper_application.py index 137bf3f9..9002b13d 100644 --- a/tests/test_v7_paper_application.py +++ b/tests/test_v7_paper_application.py @@ -382,7 +382,7 @@ def test_qsl_application_origin_is_fixed() -> None: _endpoint("https://attacker.example", str(uuid.uuid4())) -def test_loaded_version_readback_uses_process_identity_and_stays_paused(monkeypatch) -> None: +def _loaded_v7_readback(monkeypatch): from quant_platform_kit.common.runtime_target import build_runtime_target from strategy_loader import load_strategy_entrypoint_for_profile, load_strategy_runtime_adapter_for_profile from us_equity_strategies import get_strategy_entrypoint @@ -409,13 +409,34 @@ def test_loaded_version_readback_uses_process_identity_and_stays_paused(monkeypa strategy_runtime=runtime, runtime_env={"V7_PAPER_SOURCE_COMMIT": "a" * 40, "K_REVISION": "paper-v7-00001"}, ) + return readback + + +def test_loaded_version_readback_uses_process_identity_and_stays_paused(monkeypatch) -> None: + readback = _loaded_v7_readback(monkeypatch) assert readback["activation_state"] == "applied_paused" assert readback["runtime_target_enabled"] is False + assert readback["ues_revision"] == "c16c81bdf21824615f7280554559adc6c721917c" assert readback["ues_revision"] == V7_APPROVED_UES_REVISION assert readback["revision_name"] == "paper-v7-00001" assert readback["runtime_loaded_receipt"]["schema_version"] == "runtime_loaded_receipt.v1" +@pytest.mark.parametrize("observed_revision", [ + "8412d8338879f335ef8830678c2733e02e53e7c7", "a" * 40, "", None, +]) +def test_loaded_version_readback_rejects_unapproved_ues(monkeypatch, observed_revision) -> None: + from application import v7_paper_application + + original = v7_paper_application._installed_revision + monkeypatch.setattr( + v7_paper_application, "_installed_revision", + lambda name: observed_revision if name == "us-equity-strategies" else original(name), + ) + with pytest.raises(V7PaperApplicationError, match="loaded UES revision"): + _loaded_v7_readback(monkeypatch) + + def test_bound_v7_loader_is_opt_in_and_normal_loader_stays_fail_closed(monkeypatch) -> None: from strategy_loader import load_strategy_entrypoint_for_profile diff --git a/uv.lock b/uv.lock index 69456423..15b314d8 100644 --- a/uv.lock +++ b/uv.lock @@ -730,7 +730,7 @@ requires-dist = [ { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=350dd38ece0952beef096893a7dcf8ae3871609a" }, { name = "requests" }, { name = "ruff", marker = "extra == 'test'" }, - { name = "us-equity-strategies", git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=8412d8338879f335ef8830678c2733e02e53e7c7" }, + { name = "us-equity-strategies", git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=c16c81bdf21824615f7280554559adc6c721917c" }, ] provides-extras = ["test"] @@ -1354,7 +1354,7 @@ wheels = [ [[package]] name = "us-equity-strategies" version = "0.7.60" -source = { git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=8412d8338879f335ef8830678c2733e02e53e7c7#8412d8338879f335ef8830678c2733e02e53e7c7" } +source = { git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=c16c81bdf21824615f7280554559adc6c721917c#c16c81bdf21824615f7280554559adc6c721917c" } dependencies = [ { name = "pandas" }, { name = "pytz" },