From 1b3794ca298d8a340c37aebc1da3106439843954 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:31:42 +0800 Subject: [PATCH 01/47] fix(monitoring): track runtime-production heartbeat branch (#315) Co-authored-by: Codex --- .github/workflows/runtime-heartbeat.yml | 1 + scripts/runtime_workflow_heartbeat.py | 2 +- tests/test_runtime_workflow_heartbeat.py | 84 ++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/.github/workflows/runtime-heartbeat.yml b/.github/workflows/runtime-heartbeat.yml index 4893be5e..949e8822 100644 --- a/.github/workflows/runtime-heartbeat.yml +++ b/.github/workflows/runtime-heartbeat.yml @@ -34,6 +34,7 @@ jobs: env: RUNTIME_HEARTBEAT_NAME: BinancePlatform Runtime RUNTIME_HEARTBEAT_WORKFLOW: main.yml + RUNTIME_HEARTBEAT_BRANCH: ${{ vars.RUNTIME_HEARTBEAT_BRANCH || 'runtime-production' }} RUNTIME_HEARTBEAT_LOOKBACK_HOURS: ${{ inputs.lookback_hours || vars.RUNTIME_HEARTBEAT_LOOKBACK_HOURS || '2.5' }} RUNTIME_HEARTBEAT_EXPECTED_INTERVAL_HOURS: ${{ vars.RUNTIME_HEARTBEAT_EXPECTED_INTERVAL_HOURS || '1' }} RUNTIME_HEARTBEAT_MAX_CONSECUTIVE_MISSES: ${{ vars.RUNTIME_HEARTBEAT_MAX_CONSECUTIVE_MISSES || '2' }} diff --git a/scripts/runtime_workflow_heartbeat.py b/scripts/runtime_workflow_heartbeat.py index f65b737b..b4cecb44 100644 --- a/scripts/runtime_workflow_heartbeat.py +++ b/scripts/runtime_workflow_heartbeat.py @@ -445,7 +445,7 @@ def _send_telegram(message: str) -> bool: def main() -> int: repository = os.environ.get("GITHUB_REPOSITORY") or "QuantStrategyLab/BinancePlatform" workflow = os.environ.get("RUNTIME_HEARTBEAT_WORKFLOW") or "main.yml" - branch = os.environ.get("RUNTIME_HEARTBEAT_BRANCH") or "main" + branch = os.environ.get("RUNTIME_HEARTBEAT_BRANCH") or "runtime-production" name = os.environ.get("RUNTIME_HEARTBEAT_NAME") or "Binance Runtime" lookback_hours = _positive_float_from_env("RUNTIME_HEARTBEAT_LOOKBACK_HOURS", 2.5) expected_interval_hours = _positive_float_from_env("RUNTIME_HEARTBEAT_EXPECTED_INTERVAL_HOURS", 1.0) diff --git a/tests/test_runtime_workflow_heartbeat.py b/tests/test_runtime_workflow_heartbeat.py index 264128bb..820f866e 100644 --- a/tests/test_runtime_workflow_heartbeat.py +++ b/tests/test_runtime_workflow_heartbeat.py @@ -97,6 +97,86 @@ def test_runtime_heartbeat_workflow_passes_notification_locale(self) -> None: self.assertIn("NOTIFY_LANG: ${{ vars.NOTIFY_LANG || 'zh' }}", workflow) + def test_runtime_heartbeat_workflow_defaults_branch_to_runtime_production(self) -> None: + workflow = ( + Path(__file__).resolve().parents[1] + / ".github" + / "workflows" + / "runtime-heartbeat.yml" + ).read_text(encoding="utf-8") + + self.assertIn( + "RUNTIME_HEARTBEAT_BRANCH: ${{ vars.RUNTIME_HEARTBEAT_BRANCH || 'runtime-production' }}", + workflow, + ) + + def test_main_defaults_branch_to_runtime_production_and_env_overrides(self) -> None: + captured: dict[str, object] = {} + + def fake_list_runtime_runs(**kwargs): + captured.update(kwargs) + return [] + + base_env = { + "GITHUB_TOKEN": "token-1", + "RUNTIME_TARGET_ENABLED": "true", + "RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT": "false", + } + with patch.dict(os.environ, base_env, clear=True): + with patch.object(heartbeat, "_list_runtime_runs", side_effect=fake_list_runtime_runs): + with patch.object(heartbeat, "_send_telegram"): + self.assertEqual(heartbeat.main(), 0) + self.assertEqual(captured.get("branch"), "runtime-production") + + captured.clear() + with patch.dict( + os.environ, + {**base_env, "RUNTIME_HEARTBEAT_BRANCH": "custom-branch"}, + clear=True, + ): + with patch.object(heartbeat, "_list_runtime_runs", side_effect=fake_list_runtime_runs): + with patch.object(heartbeat, "_send_telegram"): + self.assertEqual(heartbeat.main(), 0) + self.assertEqual(captured.get("branch"), "custom-branch") + + def test_runtime_production_strategy_success_is_recognized(self) -> None: + now = dt.datetime.now(dt.timezone.utc) + strategy = dict( + _runtime_run(created_at=now - dt.timedelta(minutes=20), run_number=42), + display_title="Runtime · strategy", + path=".github/workflows/main.yml", + head_branch="runtime-production", + ) + noise = [ + dict(_runtime_run(created_at=now - dt.timedelta(minutes=15), run_number=i), display_title=title, path=".github/workflows/main.yml") + for i, title in enumerate( + [ + "Runtime · heartbeat", + "Runtime · reconciliation", + "Runtime · validation", + "Runtime · disabled", + ], + 100, + ) + ] + with patch.object( + heartbeat, + "_github_request", + return_value={"workflow_runs": [strategy, *noise]}, + ) as request: + result = heartbeat._list_runtime_runs( + repository="org/repo", + workflow="main.yml", + token="test", + branch="runtime-production", + per_page=30, + since=now - dt.timedelta(hours=3), + ) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["display_title"], "Runtime · strategy") + query = urllib.parse.parse_qs(urllib.parse.urlsplit(request.call_args.args[0]).query) + self.assertEqual(query["branch"], ["runtime-production"]) + def test_disabled_target_skips_github_api_lookup_and_writes_assessment(self) -> None: with patch.dict( os.environ, @@ -295,6 +375,10 @@ def fake_github_request(url: str, token: str) -> dict[str, object]: self.assertTrue(any("/actions/workflows/main.yml/runs?" in url for url in requested_urls)) self.assertTrue(any("/actions/runs?" in url for url in requested_urls)) + self.assertTrue( + any("branch=runtime-production" in url for url in requested_urls), + requested_urls, + ) send_telegram.assert_not_called() def test_send_telegram_rejects_api_ok_false(self) -> None: From 8c24458033ebd0ce42e36e7939a82902c3265945 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:34:22 +0800 Subject: [PATCH 02/47] fix: isolate Binance monitor identity (#316) Co-authored-by: Codex --- .../workflows/runtime-target-lifecycle.yml | 20 +++--- .github/workflows/watchdog.yml | 10 +-- tests/test_watchdog_workflow.py | 63 +++++++++++++++---- 3 files changed, 68 insertions(+), 25 deletions(-) diff --git a/.github/workflows/runtime-target-lifecycle.yml b/.github/workflows/runtime-target-lifecycle.yml index 6bc813c8..05b054d9 100644 --- a/.github/workflows/runtime-target-lifecycle.yml +++ b/.github/workflows/runtime-target-lifecycle.yml @@ -11,7 +11,8 @@ concurrency: jobs: publish: - name: Publish Binance target state (read-only) + name: Publish Binance monitor state (non-trading) + if: ${{ github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -19,9 +20,9 @@ jobs: contents: read id-token: write env: - GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} - GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} + GCP_PROJECT_ID: ${{ vars.BINANCE_MONITOR_GCP_PROJECT_ID }} + GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.BINANCE_MONITOR_GCP_WORKLOAD_IDENTITY_PROVIDER }} + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.BINANCE_MONITOR_GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }} RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED || 'false' }} EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} @@ -51,6 +52,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RUNTIME_HEARTBEAT_NAME: BinancePlatform Runtime RUNTIME_HEARTBEAT_WORKFLOW: main.yml + RUNTIME_HEARTBEAT_BRANCH: runtime-production RUNTIME_HEARTBEAT_LOOKBACK_HOURS: ${{ vars.RUNTIME_HEARTBEAT_LOOKBACK_HOURS || '2.5' }} RUNTIME_HEARTBEAT_EXPECTED_INTERVAL_HOURS: ${{ vars.RUNTIME_HEARTBEAT_EXPECTED_INTERVAL_HOURS || '1' }} RUNTIME_HEARTBEAT_MAX_CONSECUTIVE_MISSES: ${{ vars.RUNTIME_HEARTBEAT_MAX_CONSECUTIVE_MISSES || '2' }} @@ -62,7 +64,7 @@ jobs: test -s "$RUNTIME_HEARTBEAT_OUTPUT_PATH" echo "path=$RUNTIME_HEARTBEAT_OUTPUT_PATH" >> "$GITHUB_OUTPUT" - - name: Validate read-only GCP identity configuration + - name: Validate monitor GCP identity configuration if: ${{ steps.target.outputs.configured_state == 'enabled' && steps.target.outputs.runtime_guard == 'pass' }} shell: bash run: | @@ -73,7 +75,7 @@ jobs: exit 1 fi done - readonly EXPECTED_OIDC_IDENTITY_SHA256="68e87a5dc1bbe2e41af33d526514034246c6487fb7b716596cc75fe1c739a6b9" + readonly EXPECTED_OIDC_IDENTITY_SHA256="06c759f37689f6d9562607cc9deab70045b6b895d6aba0a42c3c963f984c2d4d" actual_oidc_identity_sha256="$( printf '%s\0%s\0%s' \ "$GCP_PROJECT_ID" \ @@ -87,7 +89,7 @@ jobs: exit 1 fi - - name: Authenticate read-only report monitor to Google Cloud + - name: Authenticate monitor to Google Cloud if: ${{ steps.target.outputs.configured_state == 'enabled' && steps.target.outputs.runtime_guard == 'pass' }} uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3 with: @@ -115,7 +117,7 @@ jobs: test -s "$RUNTIME_HEARTBEAT_OUTPUT_PATH" echo "path=$RUNTIME_HEARTBEAT_OUTPUT_PATH" >> "$GITHUB_OUTPUT" - - name: Publish read-only runtime execution evidence + - name: Publish monitor execution evidence if: ${{ steps.target.outputs.configured_state == 'enabled' && steps.execution-heartbeat.outcome == 'success' }} uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence@bbacba04baccbca91e4ba59c4dcaf460847e29f7 with: @@ -126,7 +128,7 @@ jobs: env: EXECUTION_EVIDENCE_SYNC_TOKEN: ${{ secrets.EXECUTION_EVIDENCE_SYNC_TOKEN }} - - name: Map read-only observations to shared lifecycle checks + - name: Map monitor observations to shared lifecycle checks id: monitoring env: CONFIGURED_STATE: ${{ steps.target.outputs.configured_state }} diff --git a/.github/workflows/watchdog.yml b/.github/workflows/watchdog.yml index b6681272..197c74ad 100644 --- a/.github/workflows/watchdog.yml +++ b/.github/workflows/watchdog.yml @@ -10,15 +10,15 @@ permissions: contents: read env: - GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} - GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }} - GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} + GCP_PROJECT_ID: ${{ vars.BINANCE_MONITOR_GCP_PROJECT_ID }} + GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.BINANCE_MONITOR_GCP_WORKLOAD_IDENTITY_PROVIDER }} + GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.BINANCE_MONITOR_GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }} RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED || 'false' }} WATCHDOG_MAX_AGE_SECONDS: ${{ vars.WATCHDOG_MAX_AGE_SECONDS || '4500' }} jobs: check: - if: ${{ vars.RUNTIME_TARGET_ENABLED == 'true' }} + if: ${{ github.ref == 'refs/heads/main' && vars.RUNTIME_TARGET_ENABLED == 'true' }} runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -32,7 +32,7 @@ jobs: exit 1 fi done - readonly EXPECTED_OIDC_IDENTITY_SHA256="68e87a5dc1bbe2e41af33d526514034246c6487fb7b716596cc75fe1c739a6b9" + readonly EXPECTED_OIDC_IDENTITY_SHA256="06c759f37689f6d9562607cc9deab70045b6b895d6aba0a42c3c963f984c2d4d" actual_oidc_identity_sha256="$( printf '%s\0%s\0%s' \ "$GCP_PROJECT_ID" \ diff --git a/tests/test_watchdog_workflow.py b/tests/test_watchdog_workflow.py index a34c0569..cc14d13a 100644 --- a/tests/test_watchdog_workflow.py +++ b/tests/test_watchdog_workflow.py @@ -11,7 +11,8 @@ ROOT = Path(__file__).resolve().parents[1] WORKFLOW = ROOT / ".github" / "workflows" / "watchdog.yml" -RUNTIME_WORKFLOW = ROOT / ".github" / "workflows" / "main.yml" +MONITOR_RUNTIME_WORKFLOW = ROOT / ".github" / "workflows" / "runtime-target-lifecycle.yml" +PRODUCTION_RUNTIME_WORKFLOW = ROOT / ".github" / "workflows" / "main.yml" PYPROJECT = ROOT / "pyproject.toml" LOCK = ROOT / "uv.lock" QSL = ROOT / "qsl.toml" @@ -28,8 +29,8 @@ def _identity_digest(values: dict[str, str]) -> str: return hashlib.sha256(canonical).hexdigest() -def _preflight_script(workflow_text: str) -> str: - step = workflow_text.index("Validate deployment identity configuration") +def _preflight_script(workflow_text: str, label: str = "Validate deployment identity configuration") -> str: + step = workflow_text.index(label) run_marker = " run: |\n" start = workflow_text.index(run_marker, step) + len(run_marker) lines: list[str] = [] @@ -88,7 +89,8 @@ def test_watchdog_uses_repository_variables_before_remote_actions(self) -> None: "GCP_WORKLOAD_IDENTITY_PROVIDER", "GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT", ): - self.assertIn(f"{name}: ${{{{ vars.{name} }}}}", text) + variable_name = f"BINANCE_MONITOR_{name}" + self.assertIn(f"{name}: ${{{{ vars.{variable_name} }}}}", text) self.assertIn( "for name in GCP_PROJECT_ID GCP_WORKLOAD_IDENTITY_PROVIDER " "GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT; do", @@ -116,15 +118,41 @@ def test_watchdog_skips_entire_job_when_runtime_target_is_not_explicitly_enabled job = _job_block(text, "check") self.assertIn("RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED || 'false' }}", text) - self.assertIn("if: ${{ vars.RUNTIME_TARGET_ENABLED == 'true' }}", job) + self.assertIn( + "if: ${{ github.ref == 'refs/heads/main' && vars.RUNTIME_TARGET_ENABLED == 'true' }}", + job, + ) self.assertLess( - job.index("if: ${{ vars.RUNTIME_TARGET_ENABLED == 'true' }}"), + job.index("if: ${{ github.ref == 'refs/heads/main'"), job.index("runs-on: ubuntu-latest"), ) + def test_monitor_workflows_reject_dispatch_from_untrusted_refs(self) -> None: + workflows = ( + (WORKFLOW, "check"), + (MONITOR_RUNTIME_WORKFLOW, "publish"), + ) + for workflow, job_name in workflows: + text = workflow.read_text(encoding="utf-8") + job = _job_block(text, job_name) + self.assertIn("if: ${{ github.ref == 'refs/heads/main'", job) + + def test_runtime_lifecycle_queries_protected_production_ref_explicitly(self) -> None: + text = MONITOR_RUNTIME_WORKFLOW.read_text(encoding="utf-8") + self.assertIn("RUNTIME_HEARTBEAT_BRANCH: runtime-production", text) + self.assertNotIn("RUNTIME_HEARTBEAT_BRANCH: ${{ vars.", text) + + def test_runtime_lifecycle_does_not_claim_publishing_is_read_only(self) -> None: + text = MONITOR_RUNTIME_WORKFLOW.read_text(encoding="utf-8") + self.assertNotIn("(read-only)", text) + self.assertNotIn("Publish read-only runtime execution evidence", text) + def test_oidc_identity_digest_is_fixed_and_shared(self) -> None: scripts = ( - _preflight_script(RUNTIME_WORKFLOW.read_text(encoding="utf-8")), + _preflight_script( + MONITOR_RUNTIME_WORKFLOW.read_text(encoding="utf-8"), + "Validate monitor GCP identity configuration", + ), _preflight_script(self.workflow_text), ) digests: list[str] = [] @@ -153,8 +181,11 @@ def test_oidc_identity_digest_fails_closed_without_value_output(self) -> None: } expected = _identity_digest(baseline) - for workflow in (RUNTIME_WORKFLOW, WORKFLOW): - script = _preflight_script(workflow.read_text(encoding="utf-8")) + for workflow, label in ( + (MONITOR_RUNTIME_WORKFLOW, "Validate monitor GCP identity configuration"), + (WORKFLOW, "Validate deployment identity configuration"), + ): + script = _preflight_script(workflow.read_text(encoding="utf-8"), label) script, replacements = EXPECTED_DIGEST_PATTERN.subn( f'EXPECTED_OIDC_IDENTITY_SHA256="{expected}"', script, @@ -201,6 +232,16 @@ def test_oidc_identity_digest_fails_closed_without_value_output(self) -> None: if value: self.assertNotIn(value, output) + def test_production_runtime_keeps_its_existing_identity_contract(self) -> None: + text = PRODUCTION_RUNTIME_WORKFLOW.read_text(encoding="utf-8") + for name in IDENTITY_NAMES: + self.assertIn(f"{name}: ${{{{ vars.{name} }}}}", text) + script = _preflight_script(text) + self.assertIn( + 'readonly EXPECTED_OIDC_IDENTITY_SHA256="68e87a5dc1bbe2e41af33d526514034246c6487fb7b716596cc75fe1c739a6b9"', + script, + ) + def test_watchdog_installs_locked_internal_dependency(self) -> None: text = self.workflow_text @@ -209,7 +250,7 @@ def test_watchdog_installs_locked_internal_dependency(self) -> None: self.assertIn("uv run --no-sync python - <<'PY'", text) def test_runtime_workflow_uses_cached_uv_environment(self) -> None: - text = RUNTIME_WORKFLOW.read_text(encoding="utf-8") + text = PRODUCTION_RUNTIME_WORKFLOW.read_text(encoding="utf-8") self.assertIn('LOCK_FILE="uv.lock"', text) self.assertIn('HASH_FILE="${CACHE_ROOT}/uv.lock.sha256"', text) @@ -227,7 +268,7 @@ def test_runtime_workflow_uses_cached_uv_environment(self) -> None: self.assertIn('"$LOCK_FILE unchanged; reusing cached venv."', text) def test_runtime_workflow_exposes_strategy_artifact_variables(self) -> None: - text = RUNTIME_WORKFLOW.read_text(encoding="utf-8") + text = PRODUCTION_RUNTIME_WORKFLOW.read_text(encoding="utf-8") for name in ( "STRATEGY_ARTIFACT_FILE", From c128effd70dccb3958d0c1f0002cdf6017cdaa11 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:09:16 +0800 Subject: [PATCH 03/47] fix: stop blanket personal review requests Co-Authored-By: Codex --- .github/CODEOWNERS | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 943acd4d..f74a7373 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,4 @@ -* @Pigbibi +# Blanket personal review assignment is intentionally disabled. +# Do not automatically request a personal review for every pull request: +# withdrawing a request after creation cannot prevent its notification. +# Required CI and investment/runtime authorization remain independently enforced. From 2a8460ff616c0a6429602f105c4fb2c07836c944 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:53:59 +0000 Subject: [PATCH 04/47] build(deps): bump QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle Bumps [QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle](https://github.com/quantstrategylab/quantruntimesettings) from bbacba04baccbca91e4ba59c4dcaf460847e29f7 to 685c0394ae097b36f33fe7cdeb24471c9b6ece06. - [Release notes](https://github.com/quantstrategylab/quantruntimesettings/releases) - [Commits](https://github.com/quantstrategylab/quantruntimesettings/compare/bbacba04baccbca91e4ba59c4dcaf460847e29f7...685c0394ae097b36f33fe7cdeb24471c9b6ece06) --- updated-dependencies: - dependency-name: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle dependency-version: 685c0394ae097b36f33fe7cdeb24471c9b6ece06 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/main.yml | 2 +- .github/workflows/runtime-target-lifecycle.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index adb6c553..3c157eee 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -221,7 +221,7 @@ jobs: - name: Publish disabled host observation if: ${{ steps.disabled-host.outputs.publish_ready == 'true' }} - uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle@bbacba04baccbca91e4ba59c4dcaf460847e29f7 + uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle@685c0394ae097b36f33fe7cdeb24471c9b6ece06 with: source-id: binance.runtime-target-lifecycle target-id: ${{ steps.disabled-host.outputs.target_id }} diff --git a/.github/workflows/runtime-target-lifecycle.yml b/.github/workflows/runtime-target-lifecycle.yml index 05b054d9..d684a002 100644 --- a/.github/workflows/runtime-target-lifecycle.yml +++ b/.github/workflows/runtime-target-lifecycle.yml @@ -138,7 +138,7 @@ jobs: run: python scripts/runtime_target_lifecycle_monitoring.py - name: Publish target lifecycle snapshot - uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle@bbacba04baccbca91e4ba59c4dcaf460847e29f7 + uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle@685c0394ae097b36f33fe7cdeb24471c9b6ece06 with: source-id: binance.runtime-target-lifecycle target-id: ${{ steps.target.outputs.target_id }} From ac0b7fe71d1ca51e9e3cc4bd71767c5f9b6714b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:56:45 +0000 Subject: [PATCH 05/47] build(deps): bump QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence Bumps [QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence](https://github.com/quantstrategylab/quantruntimesettings) from bbacba04baccbca91e4ba59c4dcaf460847e29f7 to 685c0394ae097b36f33fe7cdeb24471c9b6ece06. - [Release notes](https://github.com/quantstrategylab/quantruntimesettings/releases) - [Commits](https://github.com/quantstrategylab/quantruntimesettings/compare/bbacba04baccbca91e4ba59c4dcaf460847e29f7...685c0394ae097b36f33fe7cdeb24471c9b6ece06) --- updated-dependencies: - dependency-name: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence dependency-version: 685c0394ae097b36f33fe7cdeb24471c9b6ece06 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/runtime-target-lifecycle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/runtime-target-lifecycle.yml b/.github/workflows/runtime-target-lifecycle.yml index d684a002..0256d888 100644 --- a/.github/workflows/runtime-target-lifecycle.yml +++ b/.github/workflows/runtime-target-lifecycle.yml @@ -119,7 +119,7 @@ jobs: - name: Publish monitor execution evidence if: ${{ steps.target.outputs.configured_state == 'enabled' && steps.execution-heartbeat.outcome == 'success' }} - uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence@bbacba04baccbca91e4ba59c4dcaf460847e29f7 + uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence@685c0394ae097b36f33fe7cdeb24471c9b6ece06 with: source-id: runtime-reports-binance report-platform: binance From f1fedcd5ca2df2fe98412b481ebd33c8c1c0db30 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:41:29 +0800 Subject: [PATCH 06/47] feat(homepage): add pinned read-only Spot and Earn facts publisher (#320) Co-authored-by: Codex --- .github/workflows/binance-account-facts.yml | 152 ++++ application/account_facts.py | 398 ++++++++++ scripts/publish_binance_account_facts.py | 93 +++ scripts/read_binance_account_facts.py | 696 ++++++++++++++++++ .../binance_account_facts.v1.synthetic.json | 1 + tests/test_binance_account_facts.py | 632 ++++++++++++++++ 6 files changed, 1972 insertions(+) create mode 100644 .github/workflows/binance-account-facts.yml create mode 100644 application/account_facts.py create mode 100644 scripts/publish_binance_account_facts.py create mode 100644 scripts/read_binance_account_facts.py create mode 100644 tests/fixtures/binance_account_facts.v1.synthetic.json create mode 100644 tests/test_binance_account_facts.py diff --git a/.github/workflows/binance-account-facts.yml b/.github/workflows/binance-account-facts.yml new file mode 100644 index 00000000..50f6c938 --- /dev/null +++ b/.github/workflows/binance-account-facts.yml @@ -0,0 +1,152 @@ +name: Binance Account Facts + +"on": + workflow_run: + workflows: ["Runtime"] + types: [completed] + workflow_dispatch: + inputs: + enabled: + description: "Run the read-only publisher; repository gate must also be true." + required: false + type: boolean + default: false + source_run_id: + description: "Exact successful Runtime run ID to validate and read." + required: false + type: string + +permissions: + actions: read + contents: read + +concurrency: + # Do not share Runtime's concurrency group; fail-closed source-window checks + # reject intervening Runtime attempts without changing the live workflow. + group: binance-account-facts-readonly + cancel-in-progress: false + +jobs: + read-and-publish: + if: >- + vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true' && + github.ref == 'refs/heads/main' && + ((github.event_name == 'workflow_run' && + github.event.workflow_run.name == 'Runtime' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'runtime-production' && + github.event.workflow_run.head_sha == '9cfcf0531d1ea176e6f26590cf15edbd31bd6567' && + github.event.workflow_run.repository.full_name == 'QuantStrategyLab/BinancePlatform') || + (github.event_name == 'workflow_dispatch' && inputs.enabled == true)) + runs-on: self-hosted + timeout-minutes: 20 + environment: binance-runtime + steps: + - name: Checkout trusted default-branch reader source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION }} + persist-credentials: false + + - name: Verify trusted checkout and latest successful Runtime run + id: preflight + shell: bash + env: + BINANCE_ACCOUNT_FACTS_ENABLED: ${{ vars.BINANCE_ACCOUNT_FACTS_ENABLED || 'false' }} + BINANCE_ACCOUNT_FACTS_READER_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} + SOURCE_RUN_ID: ${{ github.event.workflow_run.id || inputs.source_run_id }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_API_URL: ${{ github.api_url }} + run: | + set -euo pipefail + if [[ ! "$BINANCE_ACCOUNT_FACTS_READER_REVISION" =~ ^[0-9a-f]{40}$ ]] || \ + [ "$(git rev-parse HEAD)" != "$BINANCE_ACCOUNT_FACTS_READER_REVISION" ] || \ + [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "account_facts_trusted_checkout_invalid" >&2 + exit 1 + fi + uv sync --frozen --no-dev + uv run --no-sync python scripts/read_binance_account_facts.py --preflight + + - name: Create a private same-runner handoff directory + id: private-dir + shell: bash + run: | + set -euo pipefail + private_dir="$(mktemp -d "$RUNNER_TEMP/binance-account-facts.XXXXXX")" + printf 'facts_dir=%s\n' "$private_dir" >> "$GITHUB_OUTPUT" + chmod 700 "$private_dir" + + - name: Download only the exact successful Runtime report artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: binance-execution-report-${{ steps.preflight.outputs.source_run_id }} + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ steps.preflight.outputs.source_run_id }} + path: ${{ steps.private-dir.outputs.facts_dir }}/source + + - name: Read the bound Spot and Flexible Earn account quantities + id: read-account-facts + shell: bash + env: + FACTS_DIR: ${{ steps.private-dir.outputs.facts_dir }} + SOURCE_RUN_ID: ${{ steps.preflight.outputs.source_run_id }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_API_URL: ${{ github.api_url }} + BINANCE_ACCOUNT_FACTS_ENABLED: ${{ vars.BINANCE_ACCOUNT_FACTS_ENABLED || 'false' }} + BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }} + BINANCE_API_SECRET: ${{ secrets.BINANCE_API_SECRET }} + BINANCE_RISK_AUTHORITY_JSON: ${{ secrets.BINANCE_RISK_AUTHORITY_JSON }} + BINANCE_RISK_AUTHORITY_FILE: ${{ vars.BINANCE_RISK_AUTHORITY_FILE }} + BINANCE_RISK_AUTHORITY_SHA256: ${{ vars.BINANCE_RISK_AUTHORITY_SHA256 }} + BINANCE_RISK_AUTHORITY_SOURCE_REVISION: ${{ vars.BINANCE_RISK_AUTHORITY_SOURCE_REVISION }} + BINANCE_RECONCILIATION_EXPECTED_DIGESTS_JSON: ${{ vars.BINANCE_RECONCILIATION_EXPECTED_DIGESTS_JSON }} + BINANCE_RUNTIME_RELEASE_SHA: ${{ vars.BINANCE_RUNTIME_RELEASE_SHA }} + BINANCE_ACCOUNT_FACTS_BINDING_JSON: ${{ secrets.BINANCE_ACCOUNT_FACTS_BINDING_JSON }} + BINANCE_DRY_RUN: ${{ vars.BINANCE_DRY_RUN || 'true' }} + RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED || 'false' }} + RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }} + BINANCE_ACCOUNT_FACTS_READER_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} + READER_PUBLIC_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} + run: | + set -euo pipefail + uv run --no-sync python scripts/read_binance_account_facts.py \ + --report "$FACTS_DIR/source/execution_report.json" \ + --output "$FACTS_DIR/account_facts.json" + + - name: Recheck Runtime source before publishing + shell: bash + env: + SOURCE_RUN_ID: ${{ steps.preflight.outputs.source_run_id }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_API_URL: ${{ github.api_url }} + run: | + set -euo pipefail + uv run --no-sync python scripts/read_binance_account_facts.py --verify-current-source + + - name: Publish with the isolated account-facts token + shell: bash + env: + FACTS_DIR: ${{ steps.private-dir.outputs.facts_dir }} + BINANCE_ACCOUNT_FACTS_ENABLED: ${{ vars.BINANCE_ACCOUNT_FACTS_ENABLED || 'false' }} + BINANCE_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN }} + run: | + set -euo pipefail + uv run --no-sync python scripts/publish_binance_account_facts.py \ + "$FACTS_DIR/account_facts.json" + + - name: Remove this run's private report and account-facts files + if: always() + shell: bash + env: + FACTS_DIR: ${{ steps.private-dir.outputs.facts_dir }} + run: | + set -euo pipefail + if [ -n "${FACTS_DIR:-}" ] && [ -d "$FACTS_DIR" ] && [ ! -L "$FACTS_DIR" ]; then + rm -f -- "$FACTS_DIR/account_facts.json" "$FACTS_DIR/source/execution_report.json" + if [ -d "$FACTS_DIR/source" ]; then + rmdir -- "$FACTS_DIR/source" + fi + rmdir -- "$FACTS_DIR" + fi diff --git a/application/account_facts.py b/application/account_facts.py new file mode 100644 index 00000000..2b1d3005 --- /dev/null +++ b/application/account_facts.py @@ -0,0 +1,398 @@ +"""Pure validation and aggregation for the Binance homepage account facts. + +This module deliberately knows only the two read-only wallet response shapes. +It does not read runtime state, load execution authority, write accounting state, +or calculate a quote-currency valuation. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping +from datetime import datetime, timezone +from decimal import Decimal, InvalidOperation, localcontext +from typing import Any + +from quant_platform_kit.common.broker_reconciliation import ( + calculate_broker_observation_sha256, +) + + +SCHEMA_VERSION = "binance_account_facts.v1" +SCOPE = "spot+flexible_earn" +SOURCE_BINDING_KIND = "binance_readonly_scope_revision" +UNCOVERED_SCOPES = ["funding", "margin", "futures", "locked_earn"] +PAGE_SIZE = 100 +MAX_EARN_POSITIONS = 10_000 +_HEX_SHA256 = re.compile(r"[0-9a-f]{64}\Z") +_GIT_SHA = re.compile(r"[0-9a-f]{40}\Z") +_ASSET = re.compile(r"[A-Z0-9]{1,128}\Z") + + +class AccountFactsUnavailable(ValueError): + """A fixed, provider-independent failure code safe for workflow logs.""" + + +class ReadOnlyBinanceClient: + """Expose exactly the two Binance wallet GET methods used by the reader.""" + + __slots__ = ("__client",) + + def __init__(self, client: Any) -> None: + self.__client = client + + def get_account(self) -> Any: + return self.__client.get_account() + + def get_simple_earn_flexible_product_position( + self, *, current: int, size: int + ) -> Any: + return self.__client.get_simple_earn_flexible_product_position( + current=current, size=size + ) + + +def _fail(code: str) -> AccountFactsUnavailable: + return AccountFactsUnavailable(f"binance_account_facts_{code}") + + +def _utc_timestamp(value: datetime) -> str: + if not isinstance(value, datetime) or value.tzinfo is None: + raise _fail("timestamp_invalid") + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _parsed_timestamp(value: Any) -> datetime: + if not isinstance(value, str): + raise _fail("payload_invalid") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + raise _fail("payload_invalid") from None + if parsed.tzinfo is None: + raise _fail("payload_invalid") + return parsed.astimezone(timezone.utc) + + +def _decimal(value: Any) -> Decimal: + if not isinstance(value, str) or not 0 < len(value) <= 80: + raise _fail("amount_invalid") + try: + result = Decimal(value) + except InvalidOperation: + raise _fail("amount_invalid") from None + if not result.is_finite() or result < 0: + raise _fail("amount_invalid") + _decimal_text(result) + return result + + +def _decimal_text(value: Decimal) -> str: + if not value.is_finite() or value < 0: + raise _fail("amount_invalid") + if value.is_zero(): + return "0" + sign, raw_digits, exponent = value.as_tuple() + digits = list(raw_digits) + while digits and digits[-1] == 0: + digits.pop() + exponent += 1 + del sign + whole_digits = max(1, len(digits) + exponent) + fraction_digits = max(0, -exponent) + if whole_digits > 30 or fraction_digits > 30: + raise _fail("amount_invalid") + try: + normalized = Decimal((0, tuple(digits), exponent)) + return format(normalized, "f") + except Exception: + raise _fail("amount_invalid") from None + + +def build_source_binding_id( + *, account_scope_sha256: str, reader_public_revision: str, + approved_application_revision: str, +) -> str: + """Hash the canonical scope/UID/revision tuple used by the QRS binding.""" + if not _HEX_SHA256.fullmatch(str(account_scope_sha256)): + raise _fail("account_identity_unbound") + if not _GIT_SHA.fullmatch(str(reader_public_revision)): + raise _fail("reader_revision_invalid") + if not _GIT_SHA.fullmatch(str(approved_application_revision)): + raise _fail("application_revision_invalid") + material = { + "approved_application_revision": approved_application_revision, + "account_scope_sha256": account_scope_sha256, + "reader_public_revision": reader_public_revision, + "scope": SCOPE, + } + encoded = json.dumps( + material, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def collect_account_facts( + client: ReadOnlyBinanceClient, + *, + expected_account_scope_sha256: str, + target_id: str, + reader_public_revision: str, + approved_application_revision: str, + observed_started_at: datetime, + clock, +) -> dict[str, Any]: + """Collect complete Spot plus Flexible Earn quantities, without valuation.""" + if not isinstance(client, ReadOnlyBinanceClient): + raise _fail("readonly_client_required") + if not isinstance(target_id, str) or not target_id.strip(): + raise _fail("target_binding_invalid") + if not _HEX_SHA256.fullmatch(str(expected_account_scope_sha256)): + raise _fail("account_identity_unbound") + if not _GIT_SHA.fullmatch(str(reader_public_revision)): + raise _fail("reader_revision_invalid") + if not _GIT_SHA.fullmatch(str(approved_application_revision)): + raise _fail("application_revision_invalid") + started = _utc_timestamp(observed_started_at) + started_at_utc = observed_started_at.astimezone(timezone.utc) + + try: + account = client.get_account() + except Exception: + raise _fail("spot_read_failed") from None + if not isinstance(account, Mapping): + raise _fail("spot_response_invalid") + uid = account.get("uid") + if isinstance(uid, bool) or not isinstance(uid, (str, int)) or not str(uid): + raise _fail("account_identity_missing") + account_scope_sha256 = calculate_broker_observation_sha256( + {"account_uid": str(uid)} + ) + if account_scope_sha256 != expected_account_scope_sha256: + raise _fail("account_identity_mismatch") + + balances = account.get("balances") + if not isinstance(balances, list) or len(balances) > 5000: + raise _fail("spot_response_invalid") + assets: dict[str, dict[str, Decimal]] = {} + for row in balances: + if not isinstance(row, Mapping): + raise _fail("spot_response_invalid") + asset = row.get("asset") + if ( + not isinstance(asset, str) + or not _ASSET.fullmatch(asset) + or asset in assets + ): + raise _fail("spot_response_invalid") + assets[asset] = { + "spot_free": _decimal(row.get("free")), + "spot_locked": _decimal(row.get("locked")), + "flexible_earn": Decimal(0), + } + spot_finished_at = clock() + spot_finished = _utc_timestamp(spot_finished_at) + if spot_finished_at.astimezone(timezone.utc) < started_at_utc: + raise _fail("observation_time_invalid") + + current = 1 + total_positions: int | None = None + seen_products: set[str] = set() + while True: + try: + response = client.get_simple_earn_flexible_product_position( + current=current, size=PAGE_SIZE + ) + except Exception: + raise _fail("flexible_earn_read_failed") from None + if not isinstance(response, Mapping): + raise _fail("flexible_earn_page_invalid") + rows, page_total = response.get("rows"), response.get("total") + if ( + not isinstance(rows, list) + or type(page_total) is not int + or page_total < 0 + or page_total > MAX_EARN_POSITIONS + or len(rows) > PAGE_SIZE + or (total_positions is not None and total_positions != page_total) + ): + raise _fail("flexible_earn_page_invalid") + total_positions = page_total + expected_rows = min(PAGE_SIZE, max(0, page_total - (current - 1) * PAGE_SIZE)) + if len(rows) != expected_rows: + raise _fail("flexible_earn_page_incomplete") + for row in rows: + if not isinstance(row, Mapping): + raise _fail("flexible_earn_page_invalid") + asset = row.get("asset") + product_id = row.get("productId") + if ( + not isinstance(asset, str) + or not _ASSET.fullmatch(asset) + or not isinstance(product_id, str) + or not product_id + or product_id in seen_products + ): + raise _fail("flexible_earn_page_invalid") + if asset not in assets: + # Do not synthesize missing Spot components as zero for an + # Earn-only asset; the source response must explicitly bind it. + raise _fail("spot_asset_unobserved") + seen_products.add(product_id) + amount = _decimal(row.get("totalAmount")) + with localcontext() as context: + context.prec = 100 + assets[asset]["flexible_earn"] += amount + if len(seen_products) > MAX_EARN_POSITIONS: + raise _fail("flexible_earn_page_invalid") + if len(seen_products) == page_total: + break + current += 1 + if current > (MAX_EARN_POSITIONS // PAGE_SIZE) + 1: + raise _fail("flexible_earn_page_incomplete") + earn_finished_at = clock() + earn_finished = _utc_timestamp(earn_finished_at) + finished_at = clock() + finished = _utc_timestamp(finished_at) + if ( + earn_finished_at.astimezone(timezone.utc) < spot_finished_at.astimezone(timezone.utc) + or finished_at.astimezone(timezone.utc) < earn_finished_at.astimezone(timezone.utc) + ): + raise _fail("observation_time_invalid") + + try: + with localcontext() as context: + context.prec = 100 + asset_rows = [] + for asset in sorted(assets): + row = assets[asset] + quantity = row["spot_free"] + row["spot_locked"] + row["flexible_earn"] + if quantity == 0: + continue + asset_rows.append( + { + "asset": asset, + "quantity": _decimal_text(quantity), + "spot_free": _decimal_text(row["spot_free"]), + "spot_locked": _decimal_text(row["spot_locked"]), + "flexible_earn": _decimal_text(row["flexible_earn"]), + } + ) + except AccountFactsUnavailable: + raise + except Exception: + raise _fail("amount_invalid") from None + return { + "schema_version": SCHEMA_VERSION, + "platform": "binance", + "target_id": target_id, + "account_scope_sha256": account_scope_sha256, + "source_binding": { + "kind": SOURCE_BINDING_KIND, + "id": build_source_binding_id( + account_scope_sha256=account_scope_sha256, + reader_public_revision=reader_public_revision, + approved_application_revision=approved_application_revision, + ), + }, + "source_revision": reader_public_revision, + "approved_application_revision": approved_application_revision, + "observed_started_at": started, + "observed_finished_at": finished, + "spot_observed_at": spot_finished, + "earn_observed_at": earn_finished, + "snapshot_atomic": False, + "scope": SCOPE, + "completeness": "complete_for_scope", + "assets": asset_rows, + "uncovered_scopes": list(UNCOVERED_SCOPES), + "no_order": True, + "execution_authority_granted": False, + } + + +def validate_account_facts_payload(payload: Any) -> dict[str, Any]: + """Validate a complete private projection before it can be published.""" + required = { + "schema_version", "platform", "target_id", "account_scope_sha256", + "source_binding", "source_revision", "approved_application_revision", + "observed_started_at", "observed_finished_at", "spot_observed_at", + "earn_observed_at", "snapshot_atomic", "scope", "completeness", + "assets", "uncovered_scopes", "no_order", "execution_authority_granted", + } + if not isinstance(payload, Mapping) or set(payload) != required: + raise _fail("payload_invalid") + if ( + payload.get("schema_version") != SCHEMA_VERSION + or payload.get("platform") != "binance" + or not isinstance(payload.get("target_id"), str) + or not payload["target_id"].strip() + or payload.get("scope") != SCOPE + or payload.get("completeness") != "complete_for_scope" + or payload.get("snapshot_atomic") is not False + or payload.get("no_order") is not True + or payload.get("execution_authority_granted") is not False + or payload.get("uncovered_scopes") != UNCOVERED_SCOPES + or not _HEX_SHA256.fullmatch(str(payload.get("account_scope_sha256", ""))) + or not _GIT_SHA.fullmatch(str(payload.get("source_revision", ""))) + or not _GIT_SHA.fullmatch(str(payload.get("approved_application_revision", ""))) + ): + raise _fail("payload_invalid") + binding = payload.get("source_binding") + if ( + not isinstance(binding, Mapping) + or set(binding) != {"kind", "id"} + or binding.get("kind") != SOURCE_BINDING_KIND + or not _HEX_SHA256.fullmatch(str(binding.get("id", ""))) + or binding["id"] != build_source_binding_id( + account_scope_sha256=payload["account_scope_sha256"], + reader_public_revision=payload["source_revision"], + approved_application_revision=payload["approved_application_revision"], + ) + ): + raise _fail("payload_invalid") + observation_times = {} + for key in ( + "observed_started_at", "observed_finished_at", "spot_observed_at", "earn_observed_at" + ): + observation_times[key] = _parsed_timestamp(payload.get(key)) + if not ( + observation_times["observed_started_at"] + <= observation_times["spot_observed_at"] + <= observation_times["earn_observed_at"] + <= observation_times["observed_finished_at"] + ): + raise _fail("payload_invalid") + assets = payload.get("assets") + if not isinstance(assets, list): + raise _fail("payload_invalid") + seen_assets: set[str] = set() + for row in assets: + if not isinstance(row, Mapping) or set(row) != { + "asset", "quantity", "spot_free", "spot_locked", "flexible_earn" + }: + raise _fail("payload_invalid") + asset = row.get("asset") + if not isinstance(asset, str) or not _ASSET.fullmatch(asset) or asset in seen_assets: + raise _fail("payload_invalid") + seen_assets.add(asset) + amount_texts = [row.get(name) for name in ("spot_free", "spot_locked", "flexible_earn", "quantity")] + if any(not isinstance(text, str) for text in amount_texts): + raise _fail("payload_invalid") + amounts = [_decimal(text) for text in amount_texts] + if any(_decimal_text(amount) != text for amount, text in zip(amounts, amount_texts, strict=True)): + raise _fail("payload_invalid") + spot_free, spot_locked, flexible_earn, quantity = amounts + if quantity == 0: + raise _fail("payload_invalid") + with localcontext() as context: + context.prec = 100 + if quantity != spot_free + spot_locked + flexible_earn: + raise _fail("payload_invalid") + return dict(payload) diff --git a/scripts/publish_binance_account_facts.py b/scripts/publish_binance_account_facts.py new file mode 100644 index 00000000..936a7795 --- /dev/null +++ b/scripts/publish_binance_account_facts.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Publish one complete private Binance asset-quantity projection to QRS.""" +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import HTTPRedirectHandler, Request, build_opener + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from application.account_facts import AccountFactsUnavailable, validate_account_facts_payload # noqa: E402 + + +QRS_ENDPOINT = "https://qsl-strategy-switch-console.pigbibi.workers.dev/api/internal/binance-account-facts" + + +class PublishError(ValueError): + """A fixed non-sensitive publisher stop code.""" + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, new_url): + return None + + +def publish_account_facts(*, facts_path: Path, env) -> str: + if env.get("BINANCE_ACCOUNT_FACTS_ENABLED") != "true": + raise PublishError("account_facts_publish_disabled") + token = str(env.get("BINANCE_ACCOUNT_FACTS_SYNC_TOKEN") or "") + if not token or token != token.strip(): + raise PublishError("account_facts_sync_token_missing") + try: + details = facts_path.lstat() + if facts_path.is_symlink() or not facts_path.is_file() or not 0 < details.st_size <= 5_000_000: + raise PublishError("account_facts_payload_invalid") + payload = validate_account_facts_payload(json.loads(facts_path.read_text(encoding="utf-8"))) + body = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + except PublishError: + raise + except (AccountFactsUnavailable, OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError): + raise PublishError("account_facts_payload_invalid") from None + request = Request( + QRS_ENDPOINT, + data=body, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + try: + with build_opener(_NoRedirect).open(request, timeout=20) as response: + if response.status not in {200, 201}: + raise PublishError("account_facts_publish_rejected") + raw_ack = response.read(4097) + if len(raw_ack) > 4096: + raise PublishError("account_facts_publish_rejected") + ack = json.loads(raw_ack.decode("utf-8")) + except PublishError: + raise + except (HTTPError, URLError, OSError, TimeoutError, UnicodeError, json.JSONDecodeError): + raise PublishError("account_facts_publish_failed") from None + if not isinstance(ack, dict) or ack.get("status") not in {"published", "unchanged"}: + raise PublishError("account_facts_publish_rejected") + return ack["status"] + + +def main(argv: list[str] | None = None) -> int: + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("facts", type=Path) + args = parser.parse_args(argv) + try: + status = publish_account_facts(facts_path=args.facts, env=os.environ) + print(f"account_facts_publish={status}") + return 0 + except PublishError as exc: + print(str(exc), file=sys.stderr) + return 1 + except Exception: + print("account_facts_publish_failed", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/read_binance_account_facts.py b/scripts/read_binance_account_facts.py new file mode 100644 index 00000000..23c9ae00 --- /dev/null +++ b/scripts/read_binance_account_facts.py @@ -0,0 +1,696 @@ +#!/usr/bin/env python3 +"""Read a strictly scoped Binance homepage snapshot from a successful Runtime. + +The entrypoint is independent of main.py and exposes only two broker GETs. +All failures use fixed codes; private responses and IDs never reach logs. +""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping +from urllib.error import HTTPError, URLError +from urllib.parse import quote +from urllib.request import HTTPRedirectHandler, Request, build_opener + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from application.account_facts import ( # noqa: E402 + AccountFactsUnavailable, + ReadOnlyBinanceClient, + build_source_binding_id, + collect_account_facts, + validate_account_facts_payload, +) + + +REPOSITORY = "QuantStrategyLab/BinancePlatform" +APPROVED_RUNTIME_WORKFLOW_SHA = "9cfcf0531d1ea176e6f26590cf15edbd31bd6567" +APPROVED_APPLICATION_SHA = "8cb56617115fa45028e34d788e71884b6a303d77" +RUNTIME_WORKFLOW_NAME = "Runtime" +RUNTIME_BRANCH = "runtime-production" +EXPECTED_REPORT_ARTIFACT_PREFIX = "binance-execution-report-" +_GIT_SHA = re.compile(r"[0-9a-f]{40}\Z") +_SHA256 = re.compile(r"[0-9a-f]{64}\Z") + + +class ReaderError(ValueError): + """A stable non-sensitive reader stop code.""" + + +def _stop(code: str) -> ReaderError: + return ReaderError(f"account_facts_{code}") + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, new_url): + return None + + +def _json_no_duplicates(raw: bytes) -> Any: + def pairs(items): + value = {} + for key, item in items: + if key in value: + raise _stop("input_invalid") + value[key] = item + return value + + try: + return json.loads( + raw, + object_pairs_hook=pairs, + parse_constant=lambda _value: (_ for _ in ()).throw(_stop("input_invalid")), + ) + except ReaderError: + raise + except Exception: + raise _stop("input_invalid") from None + + +def _api_json(url: str, token: str, *, timeout: int = 20) -> Any: + request = Request( + url, + headers={"Accept": "application/vnd.github+json", "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28"}, + method="GET", + ) + try: + with build_opener(_NoRedirect).open(request, timeout=timeout) as response: + if response.status != 200: + raise _stop("trigger_read_failed") + data = response.read(2_000_001) + if len(data) > 2_000_000: + raise _stop("trigger_read_failed") + return _json_no_duplicates(data) + except ReaderError: + raise + except (HTTPError, URLError, OSError, TimeoutError): + raise _stop("trigger_read_failed") from None + + +def _read_job_log_text(url: str, token: str) -> bytes: + import requests + + def bounded_body(response) -> bytes: + length = response.headers.get("Content-Length") + if length is not None and (not length.isdigit() or int(length) > 25_000_000): + raise _stop("trigger_logs_unavailable") + result = bytearray() + for chunk in response.iter_content(chunk_size=64 * 1024): + if chunk: + result.extend(chunk) + if len(result) > 25_000_000: + raise _stop("trigger_logs_unavailable") + return bytes(result) + + try: + response = requests.get( + url, + headers={"Accept": "application/vnd.github+json", "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28"}, + timeout=30, + allow_redirects=False, + stream=True, + ) + with response: + status = response.status_code + location = response.headers.get("Location") + if status == 302 and location: + from urllib.parse import urlparse + + parsed = urlparse(location) + if parsed.scheme != "https" or not parsed.netloc: + raise _stop("trigger_logs_unavailable") + # GitHub's signed log URL is fetched without the GitHub token. + response = requests.get( + location, timeout=30, allow_redirects=False, stream=True + ) + with response: + if response.status_code != 200: + raise _stop("trigger_logs_unavailable") + return bounded_body(response) + if status != 200: + raise _stop("trigger_logs_unavailable") + return bounded_body(response) + except ReaderError: + raise + except (HTTPError, URLError, OSError, TimeoutError, requests.RequestException): + raise _stop("trigger_logs_unavailable") from None + + +def _validate_release_log(run_id: str, token: str, api_url: str) -> None: + jobs_url = f"{api_url}/repos/{REPOSITORY}/actions/runs/{quote(run_id)}/jobs?per_page=100" + jobs_payload = _api_json(jobs_url, token) + jobs = jobs_payload.get("jobs") if isinstance(jobs_payload, dict) else None + if not isinstance(jobs, list): + raise _stop("trigger_jobs_invalid") + matches = [] + for job in jobs: + if not isinstance(job, dict) or job.get("name") != "deploy": + continue + steps = job.get("steps") + if not isinstance(steps, list): + continue + resolve_steps = [ + step for step in steps + if isinstance(step, dict) and step.get("name") == "Resolve approved runtime release SHA" + ] + if len(resolve_steps) == 1 and resolve_steps[0].get("conclusion") == "success" and job.get("conclusion") == "success": + matches.append(job) + if len(matches) != 1 or not isinstance(matches[0].get("id"), int): + raise _stop("trigger_release_unverified") + archive_url = f"{api_url}/repos/{REPOSITORY}/actions/jobs/{matches[0]['id']}/logs" + raw_log = _read_job_log_text(archive_url, token) + try: + log_text = raw_log.decode("utf-8") + except Exception: + raise _stop("trigger_release_unverified") from None + release_lines = re.findall( + r"(?m)^(?:[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z )?" + r"Selected runtime release SHA ([0-9a-f]{40}) " + r"\(workflow github\.sha=[0-9a-f]{40} is not used as the application execution identity\)\.?\r?$", + log_text, + ) + if release_lines != [APPROVED_APPLICATION_SHA]: + raise _stop("trigger_release_mismatch") + + +def _ensure_no_active_runtime_run(*, token: str, api_url: str) -> None: + active_statuses = ("queued", "in_progress", "requested", "waiting", "pending") + for status in active_statuses: + runs = _api_json( + f"{api_url}/repos/{REPOSITORY}/actions/workflows/main.yml/runs" + f"?status={status}&per_page=100", + token, + ) + rows = runs.get("workflow_runs") if isinstance(runs, Mapping) else None + if ( + not isinstance(rows, list) + or type(runs.get("total_count")) is not int + or runs["total_count"] > 100 + ): + raise _stop("runtime_activity_unverified") + if any( + isinstance(row, Mapping) + and isinstance(row.get("path"), str) + and row["path"].split("@", 1)[0] == ".github/workflows/main.yml" + and row.get("status") == status + for row in rows + ): + raise _stop("runtime_activity_present") + + +def _ensure_latest_runtime_run(*, run_id: str, token: str, api_url: str) -> None: + latest = _api_json( + f"{api_url}/repos/{REPOSITORY}/actions/workflows/main.yml/runs" + f"?per_page=1", + token, + ) + rows = latest.get("workflow_runs") if isinstance(latest, Mapping) else None + if not isinstance(rows, list) or len(rows) != 1 or not isinstance(rows[0], Mapping): + raise _stop("runtime_latest_unverified") + row = rows[0] + if ( + str(row.get("id")) != str(run_id) + or row.get("name") != RUNTIME_WORKFLOW_NAME + or str(row.get("path") or "").split("@", 1)[0] != ".github/workflows/main.yml" + or row.get("event") != "workflow_dispatch" + or row.get("status") != "completed" + or row.get("conclusion") != "success" + or row.get("head_branch") != RUNTIME_BRANCH + or row.get("head_sha") != APPROVED_RUNTIME_WORKFLOW_SHA + or not isinstance(row.get("repository"), Mapping) + or row["repository"].get("full_name") != REPOSITORY + ): + raise _stop("runtime_run_not_latest_success") + + +def verify_source_is_current(*, run_id: str, token: str, api_url: str) -> None: + """Fail closed if any newer or active Runtime run surrounds this read.""" + _ensure_no_active_runtime_run(token=token, api_url=api_url) + _ensure_latest_runtime_run(run_id=run_id, token=token, api_url=api_url) + + +def verify_trigger_run(*, run_id: str, repository: str, token: str, api_url: str) -> dict[str, str]: + """Validate source run metadata, selected application SHA, and report artifact.""" + if repository != REPOSITORY or not re.fullmatch(r"[1-9][0-9]{0,19}", str(run_id)): + raise _stop("trigger_identity_invalid") + url = f"{api_url}/repos/{REPOSITORY}/actions/runs/{quote(str(run_id))}" + run = _api_json(url, token) + head_repo = run.get("head_repository") if isinstance(run, dict) else None + base_repo = run.get("repository") if isinstance(run, dict) else None + if ( + run.get("id") != int(run_id) + or run.get("name") != RUNTIME_WORKFLOW_NAME + or str(run.get("path") or "").split("@", 1)[0] != ".github/workflows/main.yml" + or run.get("event") != "workflow_dispatch" + or run.get("status") != "completed" + or run.get("conclusion") != "success" + or run.get("head_branch") != RUNTIME_BRANCH + or run.get("head_sha") != APPROVED_RUNTIME_WORKFLOW_SHA + or not isinstance(head_repo, Mapping) + or head_repo.get("full_name") != REPOSITORY + or not isinstance(base_repo, Mapping) + or base_repo.get("full_name") != REPOSITORY + ): + raise _stop("trigger_identity_mismatch") + _validate_release_log(str(run_id), token, api_url) + _ensure_no_active_runtime_run(token=token, api_url=api_url) + artifacts = _api_json( + f"{api_url}/repos/{REPOSITORY}/actions/runs/{quote(str(run_id))}/artifacts?per_page=100", + token, + ) + rows = artifacts.get("artifacts") if isinstance(artifacts, dict) else None + expected_name = f"{EXPECTED_REPORT_ARTIFACT_PREFIX}{run_id}" + matches = [row for row in rows if isinstance(row, Mapping) and row.get("name") == expected_name] if isinstance(rows, list) else [] + if len(matches) != 1 or matches[0].get("expired") is not False or matches[0].get("size_in_bytes", 0) <= 0: + raise _stop("trigger_report_artifact_missing") + verify_source_is_current(run_id=str(run_id), token=token, api_url=api_url) + return {"run_id": str(run_id), "report_artifact": expected_name} + + +def _valid_order_result_tree(value: Any, parent_key: str = "") -> bool: + if isinstance(value, Mapping): + for key, child in value.items(): + key_text = str(key).lower() + if any(marker in key_text for marker in ("order", "fill", "fund", "accounting", "reconcil")): + if isinstance(child, str) and child.strip().lower() in { + "unknown", "uncertain", "pending", "submission_unknown", + "filled_accounting_pending", "unverified", "error", "failed", + "incomplete", "conflict", "mismatch", + }: + return False + if "unknown" in key_text and child is True: + return False + if isinstance(child, (int, float)) and not isinstance(child, bool) and child > 0 and any( + marker in key_text for marker in ("unknown_count", "pending_count") + ): + return False + if not _valid_order_result_tree(child, key_text): + return False + return True + if isinstance(value, list): + return all(_valid_order_result_tree(item, parent_key) for item in value) + return True + + +def _report_json(path: Path) -> Mapping[str, Any]: + try: + details = path.lstat() + if path.is_symlink() or not stat.S_ISREG(details.st_mode) or not 0 < details.st_size <= 10_000_000: + raise _stop("report_invalid") + report = _json_no_duplicates(path.read_bytes()) + except ReaderError: + raise + except Exception: + raise _stop("report_invalid") from None + if not isinstance(report, Mapping): + raise _stop("report_invalid") + return report + + +def validate_strategy_report(report: Mapping[str, Any], runtime_target: Mapping[str, Any]) -> None: + target = report.get("runtime_target") + errors = report.get("error_summary") + side_effects = report.get("side_effect_summary") + expected_target_keys = ( + "platform_id", "strategy_profile", "account_scope", "account_selector", + "deployment_selector", "service_name", "dry_run_only", + ) + _validate_execution_receipt_status(report) + if ( + report.get("platform") != "binance" + or report.get("status") != "ok" + or report.get("dry_run") is not False + or report.get("standard_execution_permitted") is not True + or not isinstance(report.get("run_id"), str) + or not report["run_id"].strip() + or report.get("execution_blocked_reason") + or not isinstance(errors, Mapping) + or errors.get("errors") != [] + or not isinstance(side_effects, Mapping) + or type(side_effects.get("executed_call_count")) is not int + or side_effects.get("executed_call_count") < 0 + or not isinstance(target, Mapping) + or any(target.get(key) != runtime_target.get(key) for key in expected_target_keys) + or not _valid_order_result_tree(report) + ): + raise _stop("strategy_report_not_eligible") + + +def _validate_execution_receipt_status(report: Mapping[str, Any]) -> None: + observation = report.get("execution_receipt_observation") + expected_counters = { + "submission_attempted_count", "broker_acknowledged_count", + "partially_filled_count", "filled_count", "transport_uncertain_count", + "failed_count", + } + if not isinstance(observation, Mapping) or set(observation) != expected_counters: + raise _stop("strategy_execution_status_unverified") + counts = {} + for name, value in observation.items(): + if type(value) is not int or value < 0: + raise _stop("strategy_execution_status_unverified") + counts[name] = value + if counts["transport_uncertain_count"] > 0: + raise _stop("strategy_execution_uncertain") + attempts = counts["submission_attempted_count"] + no_execution_observed = not any(counts.values()) + has_receipt = "execution_receipt" in report + raw_receipt = report.get("execution_receipt") + if not has_receipt: + # Older approved runtimes can omit a receipt when the report has no + # attested strategy release. Zero observed submissions is still a + # bounded read-only eligibility fact; any broker-facing counter needs + # an existing, valid terminal receipt. + if no_execution_observed: + return + raise _stop("strategy_execution_receipt_required") + try: + from quant_platform_kit.common.execution_receipts import validate_execution_receipt + + receipt = validate_execution_receipt(raw_receipt) + except Exception: + raise _stop("strategy_execution_receipt_invalid") from None + target = report.get("runtime_target") + runtime_loaded = report.get("runtime_release_receipt") + strategy_release = runtime_loaded.get("strategy_release") if isinstance(runtime_loaded, Mapping) else None + if ( + not isinstance(target, Mapping) + or receipt["platform"] != "binance" + or receipt["strategy_profile"] != report.get("strategy_profile") + or receipt["execution_mode"] != target.get("execution_mode") + or not isinstance(strategy_release, Mapping) + or receipt["strategy_revision"] != strategy_release.get("strategy_revision") + ): + raise _stop("strategy_execution_receipt_invalid") + outcome = receipt["outcome"] + confirmation = receipt["broker_confirmation"] + if no_execution_observed: + if outcome not in {"no_action", "not_due"}: + raise _stop("strategy_submission_unclosed") + return + if ( + outcome != "filled" + or confirmation != "filled" + or attempts <= 0 + or counts["filled_count"] != attempts + or any( + counts[name] != 0 + for name in ( + "broker_acknowledged_count", "partially_filled_count", + "transport_uncertain_count", "failed_count", + ) + ) + ): + raise _stop("strategy_submission_unclosed") + + +def _target_identity(raw: str) -> dict[str, Any]: + decoded = _json_no_duplicates(raw.encode("utf-8")) + if not isinstance(decoded, Mapping): + raise _stop("runtime_target_invalid") + try: + from quant_platform_kit.common.runtime_target import resolve_runtime_target_from_env + from quant_platform_kit.common.live_continuity import runtime_target_permits_standard_execution + + target = resolve_runtime_target_from_env( + env={"RUNTIME_TARGET_JSON": raw}, expected_platform_id="binance" + ) + except Exception: + raise _stop("runtime_target_invalid") + value = target.to_dict() + if ( + target.dry_run_only + or not runtime_target_permits_standard_execution(target) + or not isinstance(value.get("account_scope"), str) + or not value["account_scope"] + or value["account_scope"] == "default" + or not target.account_selector + or any(not isinstance(item, str) or not item.strip() or item == "default" for item in target.account_selector) + or not isinstance(value.get("deployment_selector"), str) + or not value["deployment_selector"] + or value["deployment_selector"] == "default" + or not isinstance(value.get("service_name"), str) + or not value["service_name"] + ): + raise _stop("runtime_target_invalid") + value["account_selector"] = list(target.account_selector) + return value + + +def _binding(raw: str, target: Mapping[str, Any], expected_scope: str, reader_revision: str) -> Mapping[str, Any]: + value = _json_no_duplicates(raw.encode("utf-8")) + fields = { + "platform", "account_key", "account_scope", "target_name", "service_name", + "deployment_selector", "account_selector", "target_id", "account_scope_sha256", + "reader_revision", "approved_application_revision", "source_binding", + } + if not isinstance(value, Mapping) or set(value) != fields: + raise _stop("binding_config_invalid") + target_selectors = target.get("account_selector") + if ( + value.get("platform") != "binance" + or not isinstance(value.get("account_key"), str) or not value["account_key"].strip() + or not isinstance(value.get("target_name"), str) or not value["target_name"].strip() + or value.get("account_scope") != target.get("account_scope") + or not isinstance(value.get("service_name"), str) + or (value.get("service_name") and value.get("service_name") != target.get("service_name")) + or value.get("deployment_selector") != target.get("deployment_selector") + or not isinstance(target_selectors, list) + or len(target_selectors) != 1 + or not isinstance(value.get("account_selector"), str) + or value.get("account_selector") != target_selectors[0] + or value.get("account_scope_sha256") != expected_scope + or value.get("reader_revision") != reader_revision + or value.get("approved_application_revision") != APPROVED_APPLICATION_SHA + or not isinstance(value.get("target_id"), str) or not value["target_id"].strip() + ): + raise _stop("binding_config_mismatch") + expected_source_binding = { + "kind": "binance_readonly_scope_revision", + "id": build_source_binding_id( + account_scope_sha256=expected_scope, + reader_public_revision=reader_revision, + approved_application_revision=APPROVED_APPLICATION_SHA, + ), + } + if value.get("source_binding") != expected_source_binding: + raise _stop("binding_source_mismatch") + return value + + +def _expected_scope(raw: str) -> str: + digests = _json_no_duplicates(raw.encode("utf-8")) + expected_keys = { + "account_scope_sha256", "positions_sha256", "cash_sha256", + "open_orders_sha256", "recent_executions_sha256", "local_execution_ledger_sha256", + } + if not isinstance(digests, Mapping) or set(digests) != expected_keys: + raise _stop("account_identity_unbound") + scope = str(digests.get("account_scope_sha256") or "").lower().removeprefix("sha256:") + if not _SHA256.fullmatch(scope): + raise _stop("account_identity_unbound") + return scope + + +def _authority_payload(env: Mapping[str, str]) -> Mapping[str, Any]: + raw_text = str(env.get("BINANCE_RISK_AUTHORITY_JSON") or "") + path_text = str(env.get("BINANCE_RISK_AUTHORITY_FILE") or "") + if raw_text and path_text: + raise _stop("authority_identity_mismatch") + if raw_text: + raw = raw_text.encode("utf-8") + else: + if not path_text: + raise _stop("authority_unavailable") + path = Path(path_text) + try: + details = path.lstat() + if path.is_symlink() or not stat.S_ISREG(details.st_mode) or not 0 < details.st_size <= 65_536: + raise _stop("authority_unavailable") + raw = path.read_bytes() + except ReaderError: + raise + except Exception: + raise _stop("authority_unavailable") from None + if ( + not raw or len(raw) > 65_536 + or not _SHA256.fullmatch(str(env.get("BINANCE_RISK_AUTHORITY_SHA256") or "").lower()) + or hashlib.sha256(raw).hexdigest() != env.get("BINANCE_RISK_AUTHORITY_SHA256", "").lower() + ): + raise _stop("authority_identity_mismatch") + payload = _json_no_duplicates(raw) + expected_fields = { + "decision", "authority_scope", "runtime_target", "strategy_revision", + "runner_revision", "config_sha256", "continuous_inputs_allowed", "mandate", + } + if not isinstance(payload, Mapping) or set(payload) != expected_fields: + raise _stop("authority_identity_mismatch") + if ( + payload.get("decision") != "APPROVE" + or payload.get("authority_scope") != "LIVE" + or payload.get("continuous_inputs_allowed") is not True + or payload.get("runner_revision") != APPROVED_APPLICATION_SHA + or not _SHA256.fullmatch(str(payload.get("config_sha256") or "")) + or not _GIT_SHA.fullmatch(str(payload.get("strategy_revision") or "")) + or not isinstance(payload.get("mandate"), Mapping) + or not _GIT_SHA.fullmatch(str(env.get("BINANCE_RISK_AUTHORITY_SOURCE_REVISION") or "")) + ): + raise _stop("authority_identity_mismatch") + return payload + + +def _validate_authority_target(authority: Mapping[str, Any], target: Mapping[str, Any]) -> None: + actual = authority.get("runtime_target") + fields = ("platform_id", "strategy_profile", "account_scope", "account_selector", "deployment_selector") + if not isinstance(actual, Mapping) or any(actual.get(key) != target.get(key) for key in fields) or set(actual) != set(fields): + raise _stop("authority_target_mismatch") + + +def _write_private_json(path: Path, payload: Mapping[str, Any]) -> None: + import os + import tempfile + + if path.exists() and (path.is_symlink() or not path.is_file()): + raise _stop("output_path_invalid") + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + os.chmod(path, 0o600) + except Exception: + try: + os.close(fd) + except OSError: + pass + try: + os.unlink(temporary) + except OSError: + pass + raise _stop("output_write_failed") from None + + +def read_account_facts(*, report_path: Path, output_path: Path, env: Mapping[str, str]) -> None: + if env.get("BINANCE_ACCOUNT_FACTS_ENABLED") != "true": + raise _stop("disabled") + if env.get("RUNTIME_TARGET_ENABLED") != "true" or env.get("BINANCE_DRY_RUN") != "false": + raise _stop("runtime_not_live") + if not env.get("BINANCE_API_KEY") or not env.get("BINANCE_API_SECRET"): + raise _stop("credentials_missing") + target = _target_identity(env.get("RUNTIME_TARGET_JSON", "")) + expected_scope = _expected_scope(env.get("BINANCE_RECONCILIATION_EXPECTED_DIGESTS_JSON", "")) + reader_revision = env.get("READER_PUBLIC_REVISION", "") + if not _GIT_SHA.fullmatch(reader_revision): + raise _stop("reader_revision_invalid") + if env.get("BINANCE_RUNTIME_RELEASE_SHA") != APPROVED_APPLICATION_SHA: + raise _stop("application_revision_mismatch") + binding = _binding( + env.get("BINANCE_ACCOUNT_FACTS_BINDING_JSON", ""), target, expected_scope, reader_revision + ) + authority = _authority_payload(env) + _validate_authority_target(authority, target) + report = _report_json(report_path) + validate_strategy_report(report, target) + source_run_id = str(env.get("SOURCE_RUN_ID") or "") + github_token = str(env.get("GITHUB_TOKEN") or "") + github_api_url = str(env.get("GITHUB_API_URL") or "https://api.github.com") + verify_source_is_current( + run_id=source_run_id, token=github_token, api_url=github_api_url + ) + + # Client construction is network-silent: python-binance ping is disabled. + # The capability wrapper exposes only the two signed wallet GET endpoints. + try: + from binance.client import Client + raw_client = Client( + env["BINANCE_API_KEY"], env["BINANCE_API_SECRET"], + requests_params={"timeout": 15}, ping=False, + ) + except Exception: + raise _stop("client_initialization_failed") from None + client = ReadOnlyBinanceClient(raw_client) + started_at = datetime.now(timezone.utc) + payload = collect_account_facts( + client, + expected_account_scope_sha256=expected_scope, + target_id=str(binding["target_id"]), + reader_public_revision=reader_revision, + approved_application_revision=APPROVED_APPLICATION_SHA, + observed_started_at=started_at, + clock=lambda: datetime.now(timezone.utc), + ) + verify_source_is_current( + run_id=source_run_id, token=github_token, api_url=github_api_url + ) + _write_private_json(output_path, validate_account_facts_payload(payload)) + + +def preflight(*, run_id: str, repository: str, token: str, api_url: str, output: Path) -> None: + result = verify_trigger_run(run_id=run_id, repository=repository, token=token, api_url=api_url) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + f"source_run_id={result['run_id']}\nreport_artifact={result['report_artifact']}\n", + encoding="utf-8", + ) + + +def main(argv: list[str] | None = None) -> int: + import argparse + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--preflight", action="store_true") + parser.add_argument("--verify-current-source", action="store_true") + parser.add_argument("--report", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + try: + if args.preflight: + run_id = str(os.getenv("SOURCE_RUN_ID") or "") + preflight( + run_id=run_id, + repository=str(os.getenv("GITHUB_REPOSITORY") or ""), + token=str(os.getenv("GITHUB_TOKEN") or ""), + api_url=str(os.getenv("GITHUB_API_URL") or "https://api.github.com"), + output=Path(os.getenv("GITHUB_OUTPUT") or "account-facts-preflight.json"), + ) + print("account_facts_preflight=ready") + return 0 + if args.verify_current_source: + verify_source_is_current( + run_id=str(os.getenv("SOURCE_RUN_ID") or ""), + token=str(os.getenv("GITHUB_TOKEN") or ""), + api_url=str(os.getenv("GITHUB_API_URL") or "https://api.github.com"), + ) + print("account_facts_source_current=true") + return 0 + if args.report is None or args.output is None: + raise _stop("arguments_invalid") + read_account_facts(report_path=args.report, output_path=args.output, env=os.environ) + print("account_facts_read=complete_for_scope") + return 0 + except (ReaderError, AccountFactsUnavailable) as exc: + print(str(exc), file=sys.stderr) + return 1 + except Exception: + print("account_facts_unexpected_failure", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/binance_account_facts.v1.synthetic.json b/tests/fixtures/binance_account_facts.v1.synthetic.json new file mode 100644 index 00000000..fa5c4d9e --- /dev/null +++ b/tests/fixtures/binance_account_facts.v1.synthetic.json @@ -0,0 +1 @@ +{"account_scope_sha256":"ffc85145b8648546510725b8f636bfcc748a566522ca710d96ed6b2935824067","approved_application_revision":"8cb56617115fa45028e34d788e71884b6a303d77","assets":[{"asset":"BTC","flexible_earn":"0.25","quantity":"0.85","spot_free":"0.5","spot_locked":"0.1"},{"asset":"USDT","flexible_earn":"0","quantity":"10","spot_free":"10","spot_locked":"0"}],"completeness":"complete_for_scope","earn_observed_at":"2026-10-02T10:00:02Z","execution_authority_granted":false,"no_order":true,"observed_finished_at":"2026-10-02T10:00:03Z","observed_started_at":"2026-10-02T10:00:00Z","platform":"binance","schema_version":"binance_account_facts.v1","scope":"spot+flexible_earn","snapshot_atomic":false,"source_binding":{"id":"f2a4d3fd78eb6b9749fd4248d78d8a78a33c6651e5d9194b9bd12e21d68b20ac","kind":"binance_readonly_scope_revision"},"source_revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","spot_observed_at":"2026-10-02T10:00:01Z","target_id":"synthetic-target","uncovered_scopes":["funding","margin","futures","locked_earn"]} diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py new file mode 100644 index 00000000..202360f9 --- /dev/null +++ b/tests/test_binance_account_facts.py @@ -0,0 +1,632 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +from application.account_facts import ( + AccountFactsUnavailable, + ReadOnlyBinanceClient, + build_source_binding_id, + collect_account_facts, + validate_account_facts_payload, +) + + +APP_SHA = "8cb56617115fa45028e34d788e71884b6a303d77" +READER_SHA = "a" * 40 +UID_DIGEST = "b" * 64 +START = datetime(2026, 10, 2, 10, 0, tzinfo=timezone.utc) + + +class FakeBinance: + def __init__(self, *, earn_pages=None): + self.earn_pages = earn_pages or [ + {"rows": [{"asset": "BTC", "productId": "flex-1", "totalAmount": "0.25"}], "total": 1} + ] + self.calls = [] + + def get_account(self): + self.calls.append(("spot",)) + return { + "uid": "123456", + "balances": [ + {"asset": "BTC", "free": "0.5", "locked": "0.1"}, + {"asset": "USDT", "free": "10", "locked": "0"}, + ], + } + + def get_simple_earn_flexible_product_position(self, *, current, size): + self.calls.append(("earn", current, size)) + return self.earn_pages[current - 1] + + +def _uid_hash() -> str: + from quant_platform_kit.common.broker_reconciliation import calculate_broker_observation_sha256 + + return calculate_broker_observation_sha256({"account_uid": "123456"}) + + +def _collect(client=None, times=None): + raw = client or FakeBinance() + clock_values = iter(times or [START + timedelta(seconds=i) for i in (1, 2, 3)]) + return collect_account_facts( + ReadOnlyBinanceClient(raw), + expected_account_scope_sha256=_uid_hash(), + target_id="synthetic-target", + reader_public_revision=READER_SHA, + approved_application_revision=APP_SHA, + observed_started_at=START, + clock=lambda: next(clock_values), + ) + + +def test_collects_spot_and_all_flexible_earn_as_native_quantities(): + payload = _collect() + assert payload["scope"] == "spot+flexible_earn" + assert payload["snapshot_atomic"] is False + assert payload["uncovered_scopes"] == ["funding", "margin", "futures", "locked_earn"] + assert payload["assets"] == [ + {"asset": "BTC", "quantity": "0.85", "spot_free": "0.5", "spot_locked": "0.1", "flexible_earn": "0.25"}, + {"asset": "USDT", "quantity": "10", "spot_free": "10", "spot_locked": "0", "flexible_earn": "0"}, + ] + assert payload["source_binding"]["id"] == build_source_binding_id( + account_scope_sha256=_uid_hash(), + reader_public_revision=READER_SHA, + approved_application_revision=APP_SHA, + ) + validate_account_facts_payload(payload) + + +def test_synthetic_contract_fixture_matches_exact_producer_json(): + fixture_path = Path(__file__).parent / "fixtures" / "binance_account_facts.v1.synthetic.json" + payload = _collect(times=[START + timedelta(seconds=i) for i in (1, 2, 3)]) + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) + "\n" + assert encoded == fixture_path.read_text(encoding="utf-8") + + +def test_source_binding_hash_uses_canonical_json(): + material = { + "approved_application_revision": APP_SHA, + "account_scope_sha256": UID_DIGEST, + "reader_public_revision": READER_SHA, + "scope": "spot+flexible_earn", + } + expected = hashlib.sha256( + json.dumps(material, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode() + ).hexdigest() + assert build_source_binding_id( + account_scope_sha256=UID_DIGEST, + reader_public_revision=READER_SHA, + approved_application_revision=APP_SHA, + ) == expected + + +def test_workflow_uses_fixed_protected_reader_revision_not_trigger_sha(): + workflow = (Path(__file__).parents[1] / ".github" / "workflows" / "binance-account-facts.yml").read_text() + assert "ref: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION }}" in workflow + assert 'BINANCE_ACCOUNT_FACTS_READER_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || \'\' }}' in workflow + assert "READER_PUBLIC_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }}" in workflow + assert "ref: ${{ github.sha }}" not in workflow + assert "git rev-parse HEAD" in workflow + + +def test_rejects_incomplete_flexible_earn_page(): + client = FakeBinance(earn_pages=[{"rows": [], "total": 1}]) + with pytest.raises(AccountFactsUnavailable, match="flexible_earn_page_incomplete"): + _collect(client) + + +def test_rejects_duplicate_flexible_earn_product_across_pages(): + client = FakeBinance(earn_pages=[ + { + "rows": [ + {"asset": "BTC", "productId": f"product-{index}", "totalAmount": "0.1"} + for index in range(99) + ] + [{"asset": "BTC", "productId": "same", "totalAmount": "0.1"}], + "total": 101, + }, + {"rows": [{"asset": "BTC", "productId": "same", "totalAmount": "0.2"}], "total": 101}, + ]) + with pytest.raises(AccountFactsUnavailable, match="flexible_earn_page_invalid"): + _collect(client) + + +def test_rejects_wrong_account_uid_before_earn_read(): + class WrongAccount(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return {"uid": "other", "balances": []} + + client = WrongAccount() + with pytest.raises(AccountFactsUnavailable, match="account_identity_mismatch"): + _collect(client) + assert client.calls == [("spot",)] + + +def test_rejects_earn_asset_without_an_explicit_spot_balance_row(): + class SpotWithoutEarnAsset(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return {"uid": "123456", "balances": []} + + client = SpotWithoutEarnAsset( + earn_pages=[{"rows": [{"asset": "BTC", "productId": "flex-1", "totalAmount": "0.25"}], "total": 1}] + ) + with pytest.raises(AccountFactsUnavailable, match="spot_asset_unobserved"): + _collect(client) + + +def test_flexible_earn_aggregation_preserves_full_decimal_precision(): + client = FakeBinance(earn_pages=[{ + "rows": [ + {"asset": "BTC", "productId": "flex-1", "totalAmount": "0.12345678901234567890123456789"}, + {"asset": "BTC", "productId": "flex-2", "totalAmount": "0.12345678901234567890123456789"}, + ], + "total": 2, + }]) + payload = _collect(client) + btc = payload["assets"][0] + assert btc["flexible_earn"] == "0.24691357802469135780246913578" + + +@pytest.mark.parametrize("amount", ["1e999999999", "1e-999999999", "1234567890123456789012345678901"]) +def test_decimal_canonicalizer_rejects_unbounded_or_over_precision_values(amount): + from application.account_facts import _decimal + + with pytest.raises(AccountFactsUnavailable, match="amount_invalid"): + _decimal(amount) + + +def test_decimal_canonicalizer_removes_non_significant_zeros(): + from application.account_facts import _decimal, _decimal_text + + assert _decimal_text(_decimal("1.000000")) == "1" + + +def test_rejects_non_monotonic_observation_times(): + with pytest.raises(AccountFactsUnavailable, match="observation_time_invalid"): + _collect(times=[START - timedelta(seconds=1), START, START]) + + +def test_payload_validator_rejects_reordered_observations(): + payload = _collect() + payload["spot_observed_at"], payload["earn_observed_at"] = ( + payload["earn_observed_at"], payload["spot_observed_at"] + ) + with pytest.raises(AccountFactsUnavailable, match="payload_invalid"): + validate_account_facts_payload(payload) + + +def test_zero_earn_positions_is_a_complete_empty_page(): + client = FakeBinance(earn_pages=[{"rows": [], "total": 0}]) + payload = _collect(client) + assert client.calls == [("spot",), ("earn", 1, 100)] + assert all(row["flexible_earn"] == "0" for row in payload["assets"]) + + +def test_complete_all_zero_spot_and_earn_is_a_valid_empty_snapshot(): + class ZeroSpot(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return { + "uid": "123456", + "balances": [ + {"asset": "BTC", "free": "0", "locked": "0"}, + {"asset": "USDT", "free": "0", "locked": "0"}, + ], + } + + client = ZeroSpot(earn_pages=[{ + "rows": [{"asset": "BTC", "productId": "flex-zero", "totalAmount": "0.0"}], + "total": 1, + }]) + payload = _collect(client) + assert payload["assets"] == [] + validate_account_facts_payload(payload) + + +def test_invalid_asset_is_rejected_even_when_its_quantity_is_zero(): + client = FakeBinance(earn_pages=[{ + "rows": [{"asset": "btc", "productId": "flex-zero", "totalAmount": "0"}], + "total": 1, + }]) + with pytest.raises(AccountFactsUnavailable, match="flexible_earn_page_invalid"): + _collect(client) + + +def test_active_runtime_blocks_read_before_account_client_creation(monkeypatch): + from scripts import read_binance_account_facts as reader + + monkeypatch.setattr( + reader, + "_api_json", + lambda _url, _token: { + "total_count": 1, + "workflow_runs": [{ + "path": ".github/workflows/main.yml@refs/heads/other-branch", + "head_branch": "other-branch", + "status": "in_progress", + }], + }, + ) + with pytest.raises(reader.ReaderError, match="runtime_activity_present"): + reader._ensure_no_active_runtime_run(token="synthetic", api_url="https://api.example") + + +def test_release_log_plain_text_requires_one_exact_approved_line(monkeypatch): + from scripts import read_binance_account_facts as reader + + monkeypatch.setattr(reader, "_api_json", lambda _url, _token: {"jobs": [{ + "id": 123, + "name": "deploy", + "conclusion": "success", + "steps": [{ + "name": "Resolve approved runtime release SHA", + "conclusion": "success", + }], + }]}) + release_line = ( + f"2026-10-02T10:10:10.1234567Z Selected runtime release SHA {APP_SHA} " + f"(workflow github.sha={'f' * 40} is not used as the application execution identity)." + ) + monkeypatch.setattr(reader, "_read_job_log_text", lambda _url, _token: release_line.encode()) + reader._validate_release_log("1234", "synthetic-token", "https://api.example") + + +@pytest.mark.parametrize( + "log_text,reason", + [ + ("Selected runtime release SHA " + "a" * 40, "trigger_release_mismatch"), + ( + "Selected runtime release SHA " + APP_SHA + " (workflow github.sha=" + "f" * 40 + + " is not used as the application execution identity).\n" + + "Selected runtime release SHA " + APP_SHA + " (workflow github.sha=" + "f" * 40 + + " is not used as the application execution identity).", + "trigger_release_mismatch", + ), + ], +) +def test_release_log_rejects_missing_or_duplicate_approved_line(monkeypatch, log_text, reason): + from scripts import read_binance_account_facts as reader + + monkeypatch.setattr(reader, "_api_json", lambda _url, _token: {"jobs": [{ + "id": 123, + "name": "deploy", + "conclusion": "success", + "steps": [{ + "name": "Resolve approved runtime release SHA", + "conclusion": "success", + }], + }]}) + monkeypatch.setattr(reader, "_read_job_log_text", lambda _url, _token: log_text.encode()) + with pytest.raises(reader.ReaderError, match=reason): + reader._validate_release_log("1234", "synthetic-token", "https://api.example") + + +def test_manual_run_must_still_be_the_latest_qualifying_runtime(monkeypatch): + from scripts import read_binance_account_facts as reader + + monkeypatch.setattr( + reader, + "_api_json", + lambda _url, _token: { + "workflow_runs": [{ + "id": 200, + "name": "Runtime", + "path": ".github/workflows/main.yml@refs/heads/runtime-production", + "event": "workflow_dispatch", + "status": "completed", + "conclusion": "failure", + "head_branch": "runtime-production", + "head_sha": "9cfcf0531d1ea176e6f26590cf15edbd31bd6567", + "repository": {"full_name": "QuantStrategyLab/BinancePlatform"}, + }], + }, + ) + with pytest.raises(reader.ReaderError, match="runtime_run_not_latest_success"): + reader._ensure_latest_runtime_run( + run_id="199", token="synthetic-token", api_url="https://api.example" + ) + + +def test_runtime_target_and_protected_binding_match_canonical_selector_shape(): + from scripts import read_binance_account_facts as reader + + target = reader._target_identity(json.dumps({ + "platform_id": "binance", + "strategy_profile": "synthetic-profile", + "dry_run_only": False, + "deployment_selector": "synthetic-deployment", + "account_selector": ["synthetic-account"], + "account_scope": "synthetic-scope", + "service_name": "synthetic-service", + })) + assert target["account_selector"] == ["synthetic-account"] + binding = { + "platform": "binance", + "account_key": "synthetic-key", + "account_scope": "synthetic-scope", + "target_name": "synthetic-target", + "service_name": "synthetic-service", + "deployment_selector": "synthetic-deployment", + "account_selector": "synthetic-account", + "target_id": "synthetic-target-id", + "account_scope_sha256": UID_DIGEST, + "reader_revision": READER_SHA, + "approved_application_revision": APP_SHA, + "source_binding": { + "kind": "binance_readonly_scope_revision", + "id": build_source_binding_id( + account_scope_sha256=UID_DIGEST, + reader_public_revision=READER_SHA, + approved_application_revision=APP_SHA, + ), + }, + } + assert reader._binding(json.dumps(binding), target, UID_DIGEST, READER_SHA) == binding + + +def _eligible_strategy_report(*, receipt_outcome="no_action", confirmation=None, observation=None): + from quant_platform_kit.common.execution_receipts import build_execution_receipt + + runtime_target = { + "platform_id": "binance", + "strategy_profile": "synthetic-profile", + "dry_run_only": False, + "execution_mode": "live", + "deployment_selector": "synthetic-deployment", + "account_selector": ["synthetic-account"], + "account_scope": "synthetic-scope", + "service_name": "synthetic-service", + } + release = "c" * 40 + report = { + "platform": "binance", + "status": "ok", + "dry_run": False, + "standard_execution_permitted": True, + "run_id": "synthetic-run", + "runtime_target": runtime_target, + "strategy_profile": "synthetic-profile", + "runtime_release_receipt": { + "attestation_state": "self_attested", + "strategy_release": {"strategy_revision": release}, + }, + "error_summary": {"errors": []}, + "side_effect_summary": {"executed_call_count": 0}, + "execution_receipt_observation": observation or { + "submission_attempted_count": 0, + "broker_acknowledged_count": 0, + "partially_filled_count": 0, + "filled_count": 0, + "transport_uncertain_count": 0, + "failed_count": 0, + }, + } + report["execution_receipt"] = build_execution_receipt( + platform="binance", + strategy_profile="synthetic-profile", + strategy_revision=release, + execution_mode="live", + outcome=receipt_outcome, + broker_confirmation=confirmation, + ) + return report, runtime_target + + +def test_strategy_report_accepts_closed_execution_receipt(): + from scripts import read_binance_account_facts as reader + + report, target = _eligible_strategy_report() + reader.validate_strategy_report(report, target) + + +def test_strategy_report_accepts_missing_receipt_only_for_explicit_zero_counters(): + from scripts import read_binance_account_facts as reader + + report, target = _eligible_strategy_report() + del report["execution_receipt"] + reader.validate_strategy_report(report, target) + + +def test_strategy_report_rejects_present_null_receipt_even_with_zero_counters(): + from scripts import read_binance_account_facts as reader + + report, target = _eligible_strategy_report() + report["execution_receipt"] = None + with pytest.raises(reader.ReaderError, match="strategy_execution_receipt_invalid"): + reader.validate_strategy_report(report, target) + + +def test_strategy_report_rejects_missing_receipt_for_nonzero_submission_count(): + from scripts import read_binance_account_facts as reader + + observation = { + "submission_attempted_count": 1, + "broker_acknowledged_count": 0, + "partially_filled_count": 0, + "filled_count": 0, + "transport_uncertain_count": 0, + "failed_count": 0, + } + report, target = _eligible_strategy_report(observation=observation) + del report["execution_receipt"] + with pytest.raises(reader.ReaderError, match="strategy_execution_receipt_required"): + reader.validate_strategy_report(report, target) + + +def test_strategy_report_accepts_only_exact_terminal_fill_for_nonzero_counts(): + from scripts import read_binance_account_facts as reader + + observation = { + "submission_attempted_count": 1, + "broker_acknowledged_count": 0, + "partially_filled_count": 0, + "filled_count": 1, + "transport_uncertain_count": 0, + "failed_count": 0, + } + report, target = _eligible_strategy_report( + receipt_outcome="filled", + confirmation="filled", + observation=observation, + ) + reader.validate_strategy_report(report, target) + + +@pytest.mark.parametrize( + "counter_name,outcome,confirmation", + [ + ("broker_acknowledged_count", "broker_acknowledged", "acknowledged"), + ("partially_filled_count", "partially_filled", "partially_filled"), + ], +) +def test_strategy_report_rejects_ack_or_partial_without_terminal_fill( + counter_name, outcome, confirmation +): + from scripts import read_binance_account_facts as reader + + observation = { + "submission_attempted_count": 1, + "broker_acknowledged_count": 0, + "partially_filled_count": 0, + "filled_count": 0, + "transport_uncertain_count": 0, + "failed_count": 0, + } + observation[counter_name] = 1 + report, target = _eligible_strategy_report( + receipt_outcome=outcome, + confirmation=confirmation, + observation=observation, + ) + with pytest.raises(reader.ReaderError, match="strategy_submission_unclosed"): + reader.validate_strategy_report(report, target) + + +@pytest.mark.parametrize( + "outcome,confirmation,observation,reason", + [ + ( + "reconciliation_required", "reconciliation_required", + { + "submission_attempted_count": 1, + "broker_acknowledged_count": 0, + "partially_filled_count": 0, + "filled_count": 0, + "transport_uncertain_count": 1, + "failed_count": 0, + }, + "strategy_execution_uncertain", + ), + ( + "reconciliation_required", "reconciliation_required", + { + "submission_attempted_count": 1, + "broker_acknowledged_count": 0, + "partially_filled_count": 0, + "filled_count": 0, + "transport_uncertain_count": 0, + "failed_count": 1, + }, + "strategy_submission_unclosed", + ), + ( + "submitted", "not_observed", + { + "submission_attempted_count": 1, + "broker_acknowledged_count": 0, + "partially_filled_count": 0, + "filled_count": 0, + "transport_uncertain_count": 0, + "failed_count": 0, + }, + "strategy_submission_unclosed", + ), + ], +) +def test_strategy_report_rejects_uncertain_reconciliation_and_unclosed_submission( + outcome, confirmation, observation, reason +): + from scripts import read_binance_account_facts as reader + + report, target = _eligible_strategy_report( + receipt_outcome=outcome, + confirmation=confirmation, + observation=observation, + ) + with pytest.raises(reader.ReaderError, match=reason): + reader.validate_strategy_report(report, target) + + +@pytest.mark.parametrize("ack_status", ["published", "unchanged"]) +def test_publisher_accepts_only_qrs_idempotent_acknowledgements(tmp_path, monkeypatch, ack_status): + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _limit): + return json.dumps({"status": ack_status}).encode() + + class Opener: + def open(self, request, timeout): + assert request.method == "POST" + assert request.full_url == publisher.QRS_ENDPOINT + assert timeout == 20 + return Response() + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + assert publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", + }, + ) == ack_status + + +def test_publisher_rejects_legacy_recorded_ack(tmp_path, monkeypatch): + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, _limit): + return b'{"status":"recorded"}' + + class Opener: + def open(self, _request, timeout): + return Response() + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError, match="publish_rejected"): + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", + }, + ) From 854e8dceac456b030bbdf3b178620ff5cf45ef86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:42:58 +0000 Subject: [PATCH 07/47] build(deps): bump urllib3 from 2.7.0 to 2.8.0 Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.7.0 to 2.8.0. - [Release notes](https://github.com/urllib3/urllib3/releases) - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst) - [Commits](https://github.com/urllib3/urllib3/compare/2.7.0...2.8.0) --- updated-dependencies: - dependency-name: urllib3 dependency-version: 2.8.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 164f779c..92f3bb85 100644 --- a/uv.lock +++ b/uv.lock @@ -1817,11 +1817,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] From a7bf700c6b85bd20dbc6fb0b2962adc51006c859 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:45:22 +0800 Subject: [PATCH 08/47] fix: bind companion account facts to successful parent runtime (#322) Co-authored-by: Codex --- .github/workflows/binance-account-facts.yml | 51 ++-- scripts/read_binance_account_facts.py | 258 ++++++++++++++++++-- tests/test_binance_account_facts.py | 156 ++++++++++++ 3 files changed, 421 insertions(+), 44 deletions(-) diff --git a/.github/workflows/binance-account-facts.yml b/.github/workflows/binance-account-facts.yml index 50f6c938..5b03e650 100644 --- a/.github/workflows/binance-account-facts.yml +++ b/.github/workflows/binance-account-facts.yml @@ -1,20 +1,34 @@ name: Binance Account Facts "on": - workflow_run: - workflows: ["Runtime"] - types: [completed] + workflow_call: + inputs: + enabled: + description: "Run the isolated read-only account-facts companion job." + type: boolean + required: false + default: false + source_mode: + description: "The reusable caller uses same_parent; legacy_terminal is reserved for the existing report read." + type: string + required: false + default: same_parent workflow_dispatch: inputs: enabled: - description: "Run the read-only publisher; repository gate must also be true." + description: "Run the existing terminal-report read; repository gate must also be true." required: false type: boolean default: false source_run_id: - description: "Exact successful Runtime run ID to validate and read." + description: "Exact existing Runtime run ID to validate and read." required: false type: string + source_mode: + description: "Keep this workflow_dispatch on the single legacy terminal-report path." + required: true + type: choice + options: [legacy_terminal] permissions: actions: read @@ -30,16 +44,13 @@ jobs: read-and-publish: if: >- vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true' && - github.ref == 'refs/heads/main' && - ((github.event_name == 'workflow_run' && - github.event.workflow_run.name == 'Runtime' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.head_branch == 'runtime-production' && - github.event.workflow_run.head_sha == '9cfcf0531d1ea176e6f26590cf15edbd31bd6567' && - github.event.workflow_run.repository.full_name == 'QuantStrategyLab/BinancePlatform') || - (github.event_name == 'workflow_dispatch' && inputs.enabled == true)) + github.ref == 'refs/heads/runtime-production' && + ((inputs.enabled == true && inputs.source_mode == 'same_parent') || + (github.workflow == 'Binance Account Facts' && github.event_name == 'workflow_dispatch' && + inputs.enabled == true && inputs.source_mode == 'legacy_terminal')) runs-on: self-hosted - timeout-minutes: 20 + timeout-minutes: 5 + continue-on-error: true environment: binance-runtime steps: - name: Checkout trusted default-branch reader source @@ -48,20 +59,22 @@ jobs: ref: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION }} persist-credentials: false - - name: Verify trusted checkout and latest successful Runtime run + - name: Verify trusted checkout and exact Runtime source id: preflight shell: bash env: BINANCE_ACCOUNT_FACTS_ENABLED: ${{ vars.BINANCE_ACCOUNT_FACTS_ENABLED || 'false' }} BINANCE_ACCOUNT_FACTS_READER_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} - SOURCE_RUN_ID: ${{ github.event.workflow_run.id || inputs.source_run_id }} + BINANCE_RUNTIME_WORKFLOW_SHA: ${{ vars.BINANCE_RUNTIME_WORKFLOW_SHA || '' }} + SOURCE_MODE: ${{ inputs.source_mode || 'legacy_terminal' }} + SOURCE_RUN_ID: ${{ inputs.source_run_id || '' }} GITHUB_TOKEN: ${{ github.token }} GITHUB_API_URL: ${{ github.api_url }} run: | set -euo pipefail if [[ ! "$BINANCE_ACCOUNT_FACTS_READER_REVISION" =~ ^[0-9a-f]{40}$ ]] || \ [ "$(git rev-parse HEAD)" != "$BINANCE_ACCOUNT_FACTS_READER_REVISION" ] || \ - [ "$GITHUB_REF" != "refs/heads/main" ]; then + [ "$GITHUB_REF" != "refs/heads/runtime-production" ]; then echo "account_facts_trusted_checkout_invalid" >&2 exit 1 fi @@ -94,6 +107,8 @@ jobs: SOURCE_RUN_ID: ${{ steps.preflight.outputs.source_run_id }} GITHUB_TOKEN: ${{ github.token }} GITHUB_API_URL: ${{ github.api_url }} + SOURCE_MODE: ${{ inputs.source_mode || 'legacy_terminal' }} + BINANCE_RUNTIME_WORKFLOW_SHA: ${{ vars.BINANCE_RUNTIME_WORKFLOW_SHA || '' }} BINANCE_ACCOUNT_FACTS_ENABLED: ${{ vars.BINANCE_ACCOUNT_FACTS_ENABLED || 'false' }} BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }} BINANCE_API_SECRET: ${{ secrets.BINANCE_API_SECRET }} @@ -121,6 +136,8 @@ jobs: SOURCE_RUN_ID: ${{ steps.preflight.outputs.source_run_id }} GITHUB_TOKEN: ${{ github.token }} GITHUB_API_URL: ${{ github.api_url }} + SOURCE_MODE: ${{ inputs.source_mode || 'legacy_terminal' }} + BINANCE_RUNTIME_WORKFLOW_SHA: ${{ vars.BINANCE_RUNTIME_WORKFLOW_SHA || '' }} run: | set -euo pipefail uv run --no-sync python scripts/read_binance_account_facts.py --verify-current-source diff --git a/scripts/read_binance_account_facts.py b/scripts/read_binance_account_facts.py index 23c9ae00..1f04b175 100644 --- a/scripts/read_binance_account_facts.py +++ b/scripts/read_binance_account_facts.py @@ -33,7 +33,7 @@ REPOSITORY = "QuantStrategyLab/BinancePlatform" -APPROVED_RUNTIME_WORKFLOW_SHA = "9cfcf0531d1ea176e6f26590cf15edbd31bd6567" +LEGACY_RUNTIME_WORKFLOW_SHA = "9cfcf0531d1ea176e6f26590cf15edbd31bd6567" APPROVED_APPLICATION_SHA = "8cb56617115fa45028e34d788e71884b6a303d77" RUNTIME_WORKFLOW_NAME = "Runtime" RUNTIME_BRANCH = "runtime-production" @@ -227,7 +227,7 @@ def _ensure_latest_runtime_run(*, run_id: str, token: str, api_url: str) -> None or row.get("status") != "completed" or row.get("conclusion") != "success" or row.get("head_branch") != RUNTIME_BRANCH - or row.get("head_sha") != APPROVED_RUNTIME_WORKFLOW_SHA + or row.get("head_sha") != LEGACY_RUNTIME_WORKFLOW_SHA or not isinstance(row.get("repository"), Mapping) or row["repository"].get("full_name") != REPOSITORY ): @@ -240,6 +240,204 @@ def verify_source_is_current(*, run_id: str, token: str, api_url: str) -> None: _ensure_latest_runtime_run(run_id=run_id, token=token, api_url=api_url) +def _parse_github_time(value: Any) -> datetime: + if not isinstance(value, str) or not value: + raise _stop("parent_run_unverified") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except (TypeError, ValueError): + raise _stop("parent_run_unverified") from None + if parsed.tzinfo is None: + raise _stop("parent_run_unverified") + return parsed.astimezone(timezone.utc) + + +def _ensure_no_other_active_parent_run( + *, run_id: str, token: str, api_url: str +) -> None: + active_statuses = ("queued", "in_progress", "requested", "waiting", "pending") + own_in_progress_seen = False + for status in active_statuses: + result = _api_json( + f"{api_url}/repos/{REPOSITORY}/actions/workflows/main.yml/runs" + f"?status={status}&per_page=100", + token, + ) + rows = result.get("workflow_runs") if isinstance(result, Mapping) else None + if ( + not isinstance(rows, list) + or type(result.get("total_count")) is not int + or result["total_count"] > 100 + ): + raise _stop("runtime_activity_unverified") + for row in rows: + if not isinstance(row, Mapping): + raise _stop("runtime_activity_unverified") + if str(row.get("path") or "").split("@", 1)[0] != ".github/workflows/main.yml": + raise _stop("runtime_activity_unverified") + if str(row.get("id")) == str(run_id): + if status != "in_progress" or row.get("status") != "in_progress": + raise _stop("runtime_parent_attempt_mismatch") + own_in_progress_seen = True + continue + raise _stop("runtime_activity_present") + if not own_in_progress_seen: + raise _stop("runtime_parent_attempt_mismatch") + + +def _ensure_latest_parent_run(*, run_id: str, token: str, api_url: str) -> None: + result = _api_json( + f"{api_url}/repos/{REPOSITORY}/actions/workflows/main.yml/runs?per_page=1", + token, + ) + rows = result.get("workflow_runs") if isinstance(result, Mapping) else None + if ( + not isinstance(rows, list) or len(rows) != 1 or not isinstance(rows[0], Mapping) + or type(result.get("total_count")) is not int or result["total_count"] < 1 + ): + raise _stop("runtime_latest_unverified") + row = rows[0] + if ( + str(row.get("id")) != str(run_id) + or row.get("name") != RUNTIME_WORKFLOW_NAME + or str(row.get("path") or "").split("@", 1)[0] != ".github/workflows/main.yml" + or row.get("head_branch") != RUNTIME_BRANCH + or row.get("status") != "in_progress" + ): + raise _stop("runtime_parent_not_latest") + + +def verify_parent_run( + *, run_id: str, run_attempt: int, repository: str, ref: str, github_sha: str, + runtime_workflow_sha: str, token: str, api_url: str, +) -> dict[str, str]: + """Verify the exact in-progress Runtime parent and its completed read inputs.""" + if ( + repository != REPOSITORY + or not re.fullmatch(r"[1-9][0-9]{0,19}", str(run_id)) + or type(run_attempt) is not int or run_attempt != 1 + or ref != f"refs/heads/{RUNTIME_BRANCH}" + or not _GIT_SHA.fullmatch(runtime_workflow_sha) + or github_sha != runtime_workflow_sha + ): + raise _stop("parent_run_identity_mismatch") + + _ensure_no_other_active_parent_run(run_id=run_id, token=token, api_url=api_url) + _ensure_latest_parent_run(run_id=run_id, token=token, api_url=api_url) + run = _api_json( + f"{api_url}/repos/{REPOSITORY}/actions/runs/{quote(str(run_id))}", token + ) + if ( + not isinstance(run, Mapping) + or type(run.get("id")) is not int or run.get("id") != int(run_id) + or type(run.get("run_attempt")) is not int or run.get("run_attempt") != 1 + or run.get("name") != RUNTIME_WORKFLOW_NAME + or str(run.get("path") or "").split("@", 1)[0] != ".github/workflows/main.yml" + or run.get("event") != "workflow_dispatch" + or run.get("status") != "in_progress" + or run.get("head_branch") != RUNTIME_BRANCH + or run.get("head_sha") != runtime_workflow_sha + or not isinstance(run.get("repository"), Mapping) + or run["repository"].get("full_name") != REPOSITORY + or not isinstance(run.get("head_repository"), Mapping) + or run["head_repository"].get("full_name") != REPOSITORY + ): + raise _stop("parent_run_identity_mismatch") + + jobs_result = _api_json( + f"{api_url}/repos/{REPOSITORY}/actions/runs/{quote(str(run_id))}/jobs?per_page=100", + token, + ) + jobs = jobs_result.get("jobs") if isinstance(jobs_result, Mapping) else None + if not isinstance(jobs, list) or type(jobs_result.get("total_count")) is not int or jobs_result["total_count"] > 100: + raise _stop("parent_run_jobs_unverified") + deploy_jobs = [job for job in jobs if isinstance(job, Mapping) and job.get("name") == "deploy"] + if len(deploy_jobs) != 1: + raise _stop("parent_deploy_unverified") + deploy = deploy_jobs[0] + steps = deploy.get("steps") + if ( + deploy.get("status") != "completed" or deploy.get("conclusion") != "success" + or not isinstance(steps, list) + ): + raise _stop("parent_deploy_unverified") + strategy_steps = [step for step in steps if isinstance(step, Mapping) and step.get("name") == "4. Run trading strategy"] + report_steps = [step for step in steps if isinstance(step, Mapping) and step.get("name") == "5. Stage execution report for isolated log publisher"] + if ( + len(strategy_steps) != 1 or strategy_steps[0].get("status") != "completed" + or strategy_steps[0].get("conclusion") != "success" + or len(report_steps) != 1 or report_steps[0].get("status") != "completed" + or report_steps[0].get("conclusion") != "success" + ): + raise _stop("parent_strategy_or_report_unverified") + deploy_started = _parse_github_time(deploy.get("started_at")) + strategy_started = _parse_github_time(strategy_steps[0].get("started_at")) + strategy_finished = _parse_github_time(strategy_steps[0].get("completed_at")) + report_started = _parse_github_time(report_steps[0].get("started_at")) + report_finished = _parse_github_time(report_steps[0].get("completed_at")) + if ( + strategy_started < deploy_started or strategy_finished < strategy_started + or report_started < strategy_finished or report_finished < report_started + ): + raise _stop("parent_run_jobs_unverified") + + # The protected setting and report binding identify the intended app, but + # only the Runtime release-selection log proves what this parent ran. + _validate_release_log(str(run_id), token, api_url) + + artifacts_result = _api_json( + f"{api_url}/repos/{REPOSITORY}/actions/runs/{quote(str(run_id))}/artifacts?per_page=100", + token, + ) + artifacts = artifacts_result.get("artifacts") if isinstance(artifacts_result, Mapping) else None + if ( + not isinstance(artifacts, list) + or type(artifacts_result.get("total_count")) is not int + or artifacts_result["total_count"] > 100 + ): + raise _stop("trigger_report_artifact_missing") + artifact_name = f"{EXPECTED_REPORT_ARTIFACT_PREFIX}{run_id}" + matches = [item for item in artifacts if isinstance(item, Mapping) and item.get("name") == artifact_name] + if len(matches) != 1: + raise _stop("trigger_report_artifact_missing") + artifact = matches[0] + if ( + artifact.get("expired") is not False + or type(artifact.get("size_in_bytes")) is not int or artifact["size_in_bytes"] <= 0 + or _parse_github_time(artifact.get("created_at")) < strategy_finished + or _parse_github_time(artifact.get("created_at")) < deploy_started + ): + raise _stop("trigger_report_artifact_missing") + return {"run_id": str(run_id), "report_artifact": artifact_name} + + +def verify_current_source_from_env(env: Mapping[str, str]) -> None: + mode = env.get("SOURCE_MODE", "legacy_terminal") + if mode == "same_parent": + try: + attempt = int(env.get("GITHUB_RUN_ATTEMPT", "")) + except ValueError: + raise _stop("parent_run_attempt_mismatch") from None + verify_parent_run( + run_id=str(env.get("GITHUB_RUN_ID") or ""), + run_attempt=attempt, + repository=str(env.get("GITHUB_REPOSITORY") or ""), + ref=str(env.get("GITHUB_REF") or ""), + github_sha=str(env.get("GITHUB_SHA") or ""), + runtime_workflow_sha=str(env.get("BINANCE_RUNTIME_WORKFLOW_SHA") or ""), + token=str(env.get("GITHUB_TOKEN") or ""), + api_url=str(env.get("GITHUB_API_URL") or "https://api.github.com"), + ) + elif mode == "legacy_terminal": + verify_source_is_current( + run_id=str(env.get("SOURCE_RUN_ID") or ""), + token=str(env.get("GITHUB_TOKEN") or ""), + api_url=str(env.get("GITHUB_API_URL") or "https://api.github.com"), + ) + else: + raise _stop("source_mode_invalid") + + def verify_trigger_run(*, run_id: str, repository: str, token: str, api_url: str) -> dict[str, str]: """Validate source run metadata, selected application SHA, and report artifact.""" if repository != REPOSITORY or not re.fullmatch(r"[1-9][0-9]{0,19}", str(run_id)): @@ -256,7 +454,7 @@ def verify_trigger_run(*, run_id: str, repository: str, token: str, api_url: str or run.get("status") != "completed" or run.get("conclusion") != "success" or run.get("head_branch") != RUNTIME_BRANCH - or run.get("head_sha") != APPROVED_RUNTIME_WORKFLOW_SHA + or run.get("head_sha") != LEGACY_RUNTIME_WORKFLOW_SHA or not isinstance(head_repo, Mapping) or head_repo.get("full_name") != REPOSITORY or not isinstance(base_repo, Mapping) @@ -607,12 +805,7 @@ def read_account_facts(*, report_path: Path, output_path: Path, env: Mapping[str _validate_authority_target(authority, target) report = _report_json(report_path) validate_strategy_report(report, target) - source_run_id = str(env.get("SOURCE_RUN_ID") or "") - github_token = str(env.get("GITHUB_TOKEN") or "") - github_api_url = str(env.get("GITHUB_API_URL") or "https://api.github.com") - verify_source_is_current( - run_id=source_run_id, token=github_token, api_url=github_api_url - ) + verify_current_source_from_env(env) # Client construction is network-silent: python-binance ping is disabled. # The capability wrapper exposes only the two signed wallet GET endpoints. @@ -635,14 +828,36 @@ def read_account_facts(*, report_path: Path, output_path: Path, env: Mapping[str observed_started_at=started_at, clock=lambda: datetime.now(timezone.utc), ) - verify_source_is_current( - run_id=source_run_id, token=github_token, api_url=github_api_url - ) + verify_current_source_from_env(env) _write_private_json(output_path, validate_account_facts_payload(payload)) -def preflight(*, run_id: str, repository: str, token: str, api_url: str, output: Path) -> None: - result = verify_trigger_run(run_id=run_id, repository=repository, token=token, api_url=api_url) +def preflight(*, env: Mapping[str, str], output: Path) -> None: + mode = env.get("SOURCE_MODE", "legacy_terminal") + if mode == "same_parent": + try: + attempt = int(env.get("GITHUB_RUN_ATTEMPT", "")) + except ValueError: + raise _stop("parent_run_attempt_mismatch") from None + result = verify_parent_run( + run_id=str(env.get("GITHUB_RUN_ID") or ""), + run_attempt=attempt, + repository=str(env.get("GITHUB_REPOSITORY") or ""), + ref=str(env.get("GITHUB_REF") or ""), + github_sha=str(env.get("GITHUB_SHA") or ""), + runtime_workflow_sha=str(env.get("BINANCE_RUNTIME_WORKFLOW_SHA") or ""), + token=str(env.get("GITHUB_TOKEN") or ""), + api_url=str(env.get("GITHUB_API_URL") or "https://api.github.com"), + ) + elif mode == "legacy_terminal": + result = verify_trigger_run( + run_id=str(env.get("SOURCE_RUN_ID") or ""), + repository=str(env.get("GITHUB_REPOSITORY") or ""), + token=str(env.get("GITHUB_TOKEN") or ""), + api_url=str(env.get("GITHUB_API_URL") or "https://api.github.com"), + ) + else: + raise _stop("source_mode_invalid") output.parent.mkdir(parents=True, exist_ok=True) output.write_text( f"source_run_id={result['run_id']}\nreport_artifact={result['report_artifact']}\n", @@ -661,22 +876,11 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) try: if args.preflight: - run_id = str(os.getenv("SOURCE_RUN_ID") or "") - preflight( - run_id=run_id, - repository=str(os.getenv("GITHUB_REPOSITORY") or ""), - token=str(os.getenv("GITHUB_TOKEN") or ""), - api_url=str(os.getenv("GITHUB_API_URL") or "https://api.github.com"), - output=Path(os.getenv("GITHUB_OUTPUT") or "account-facts-preflight.json"), - ) + preflight(env=os.environ, output=Path(os.getenv("GITHUB_OUTPUT") or "account-facts-preflight.json")) print("account_facts_preflight=ready") return 0 if args.verify_current_source: - verify_source_is_current( - run_id=str(os.getenv("SOURCE_RUN_ID") or ""), - token=str(os.getenv("GITHUB_TOKEN") or ""), - api_url=str(os.getenv("GITHUB_API_URL") or "https://api.github.com"), - ) + verify_current_source_from_env(os.environ) print("account_facts_source_current=true") return 0 if args.report is None or args.output is None: diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 202360f9..e6721ffb 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -112,6 +112,162 @@ def test_workflow_uses_fixed_protected_reader_revision_not_trigger_sha(): assert "READER_PUBLIC_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }}" in workflow assert "ref: ${{ github.sha }}" not in workflow assert "git rev-parse HEAD" in workflow + assert "workflow_call:" in workflow + assert "workflow_run:" not in workflow + assert "github.ref == 'refs/heads/runtime-production'" in workflow + assert "timeout-minutes: 5" in workflow + assert "continue-on-error: true" in workflow + + +def _parent_run_api(url, _token): + from urllib.parse import parse_qs, urlparse + + parsed = urlparse(url) + if "/actions/workflows/main.yml/runs?status=" in url: + status = parse_qs(parsed.query)["status"][0] + rows = [{"id": 501, "path": ".github/workflows/main.yml@refs/heads/runtime-production", "status": "in_progress"}] if status == "in_progress" else [] + return {"total_count": len(rows), "workflow_runs": rows} + if parsed.path.endswith("/actions/workflows/main.yml/runs"): + return {"total_count": 1, "workflow_runs": [{ + "id": 501, "name": "Runtime", + "path": ".github/workflows/main.yml@refs/heads/runtime-production", + "head_branch": "runtime-production", "status": "in_progress", + }]} + if parsed.path.endswith("/actions/runs/501"): + return { + "id": 501, "run_attempt": 1, "name": "Runtime", + "path": ".github/workflows/main.yml@refs/heads/runtime-production", + "event": "workflow_dispatch", "status": "in_progress", + "head_branch": "runtime-production", "head_sha": "d" * 40, + "repository": {"full_name": "QuantStrategyLab/BinancePlatform"}, + "head_repository": {"full_name": "QuantStrategyLab/BinancePlatform"}, + } + if parsed.path.endswith("/actions/runs/501/jobs"): + return {"total_count": 1, "jobs": [{ + "id": 701, "name": "deploy", "status": "completed", "conclusion": "success", + "started_at": "2026-10-02T10:00:00Z", + "steps": [ + {"name": "Resolve approved runtime release SHA", "status": "completed", "conclusion": "success"}, + {"name": "4. Run trading strategy", "status": "completed", "conclusion": "success", "started_at": "2026-10-02T10:01:00Z", "completed_at": "2026-10-02T10:05:00Z"}, + {"name": "5. Stage execution report for isolated log publisher", "status": "completed", "conclusion": "success", "started_at": "2026-10-02T10:05:01Z", "completed_at": "2026-10-02T10:06:00Z"}, + ], + }]} + if parsed.path.endswith("/actions/runs/501/artifacts"): + return {"total_count": 1, "artifacts": [{ + "name": "binance-execution-report-501", "expired": False, + "size_in_bytes": 123, "created_at": "2026-10-02T10:05:10Z", + }]} + raise AssertionError("unexpected synthetic GitHub API path") + + +def _install_parent_release_log(monkeypatch, reader, selected_sha=APP_SHA): + log_text = ( + f"Selected runtime release SHA {selected_sha} " + f"(workflow github.sha={'d' * 40} is not used as the application execution identity)." + ) + monkeypatch.setattr(reader, "_read_job_log_text", lambda *_args: log_text.encode()) + + +def test_same_parent_runtime_read_is_allowed_only_for_exact_in_progress_run(monkeypatch): + from scripts import read_binance_account_facts as reader + + monkeypatch.setattr(reader, "_api_json", _parent_run_api) + _install_parent_release_log(monkeypatch, reader) + result = reader.verify_parent_run( + run_id="501", run_attempt=1, repository="QuantStrategyLab/BinancePlatform", + ref="refs/heads/runtime-production", github_sha="d" * 40, + runtime_workflow_sha="d" * 40, token="synthetic", api_url="https://api.example", + ) + assert result == {"run_id": "501", "report_artifact": "binance-execution-report-501"} + + +@pytest.mark.parametrize( + "changes,reason", + [ + ({"run_attempt": 2}, "parent_run_identity_mismatch"), + ({"repository": "other/repository"}, "parent_run_identity_mismatch"), + ({"ref": "refs/heads/main"}, "parent_run_identity_mismatch"), + ({"github_sha": "e" * 40}, "parent_run_identity_mismatch"), + ({"runtime_workflow_sha": "bad"}, "parent_run_identity_mismatch"), + ], +) +def test_same_parent_runtime_rejects_identity_or_retry_mismatch(monkeypatch, changes, reason): + from scripts import read_binance_account_facts as reader + + monkeypatch.setattr(reader, "_api_json", lambda *_args: pytest.fail("invalid caller must stop before API")) + args = { + "run_id": "501", "run_attempt": 1, + "repository": "QuantStrategyLab/BinancePlatform", + "ref": "refs/heads/runtime-production", "github_sha": "d" * 40, + "runtime_workflow_sha": "d" * 40, "token": "synthetic", + "api_url": "https://api.example", + } + args.update(changes) + with pytest.raises(reader.ReaderError, match=reason): + reader.verify_parent_run(**args) + + +@pytest.mark.parametrize( + "mutation,reason", + [ + ("other_active", "runtime_activity_present"), + ("newer_run", "runtime_parent_not_latest"), + ("failed_strategy", "parent_strategy_or_report_unverified"), + ("failed_report_upload", "parent_strategy_or_report_unverified"), + ("early_artifact", "trigger_report_artifact_missing"), + ("duplicate_artifact", "trigger_report_artifact_missing"), + ], +) +def test_same_parent_runtime_requires_unique_completed_strategy_inputs(monkeypatch, mutation, reason): + from scripts import read_binance_account_facts as reader + + _install_parent_release_log(monkeypatch, reader) + + def api(url, token): + result = _parent_run_api(url, token) + def fail_step(step_name): + steps = result["jobs"][0]["steps"] + matches = [step for step in steps if step.get("name") == step_name] + assert len(matches) == 1 + matches[0]["conclusion"] = "skipped" if mutation == "failed_strategy" else "failure" + + if mutation == "other_active" and "status=in_progress" in url: + result["workflow_runs"].append({ + "id": 502, "path": ".github/workflows/main.yml", "status": "in_progress", + }) + result["total_count"] = 2 + elif mutation == "newer_run" and "/actions/workflows/main.yml/runs?per_page=1" in url: + result["workflow_runs"][0]["id"] = 502 + elif mutation == "failed_strategy" and url.endswith("/jobs?per_page=100"): + fail_step("4. Run trading strategy") + elif mutation == "failed_report_upload" and url.endswith("/jobs?per_page=100"): + fail_step("5. Stage execution report for isolated log publisher") + elif mutation in {"early_artifact", "duplicate_artifact"} and url.endswith("/artifacts?per_page=100"): + result["artifacts"][0]["created_at"] = "2026-10-02T10:04:59Z" + if mutation == "duplicate_artifact": + result["artifacts"].append(dict(result["artifacts"][0])) + return result + + monkeypatch.setattr(reader, "_api_json", api) + with pytest.raises(reader.ReaderError, match=reason): + reader.verify_parent_run( + run_id="501", run_attempt=1, repository="QuantStrategyLab/BinancePlatform", + ref="refs/heads/runtime-production", github_sha="d" * 40, + runtime_workflow_sha="d" * 40, token="synthetic", api_url="https://api.example", + ) + + +def test_same_parent_rejects_actual_app_release_mismatch_even_when_protected_sha_matches(monkeypatch): + from scripts import read_binance_account_facts as reader + + monkeypatch.setattr(reader, "_api_json", _parent_run_api) + _install_parent_release_log(monkeypatch, reader, selected_sha="e" * 40) + with pytest.raises(reader.ReaderError, match="trigger_release_mismatch"): + reader.verify_parent_run( + run_id="501", run_attempt=1, repository="QuantStrategyLab/BinancePlatform", + ref="refs/heads/runtime-production", github_sha="d" * 40, + runtime_workflow_sha="d" * 40, token="synthetic", api_url="https://api.example", + ) def test_rejects_incomplete_flexible_earn_page(): From 4ca436a8910eecd35335f2d04dc69cfb9be4a327 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:17:34 +0800 Subject: [PATCH 09/47] fix: qualify terminal reports from the protected production workflow (#324) Co-authored-by: Codex --- scripts/read_binance_account_facts.py | 168 +++++++++++++++++++------- tests/test_binance_account_facts.py | 109 +++++++++++++++++ 2 files changed, 233 insertions(+), 44 deletions(-) diff --git a/scripts/read_binance_account_facts.py b/scripts/read_binance_account_facts.py index 1f04b175..ee530bbc 100644 --- a/scripts/read_binance_account_facts.py +++ b/scripts/read_binance_account_facts.py @@ -209,7 +209,11 @@ def _ensure_no_active_runtime_run(*, token: str, api_url: str) -> None: raise _stop("runtime_activity_present") -def _ensure_latest_runtime_run(*, run_id: str, token: str, api_url: str) -> None: +def _ensure_latest_runtime_run( + *, run_id: str, token: str, api_url: str, + expected_workflow_sha: str = LEGACY_RUNTIME_WORKFLOW_SHA, + require_attempt_one: bool = False, +) -> None: latest = _api_json( f"{api_url}/repos/{REPOSITORY}/actions/workflows/main.yml/runs" f"?per_page=1", @@ -227,7 +231,10 @@ def _ensure_latest_runtime_run(*, run_id: str, token: str, api_url: str) -> None or row.get("status") != "completed" or row.get("conclusion") != "success" or row.get("head_branch") != RUNTIME_BRANCH - or row.get("head_sha") != LEGACY_RUNTIME_WORKFLOW_SHA + or row.get("head_sha") != expected_workflow_sha + or (require_attempt_one and ( + type(row.get("run_attempt")) is not int or row.get("run_attempt") != 1 + )) or not isinstance(row.get("repository"), Mapping) or row["repository"].get("full_name") != REPOSITORY ): @@ -252,6 +259,54 @@ def _parse_github_time(value: Any) -> datetime: return parsed.astimezone(timezone.utc) +def _validate_terminal_runtime_steps( + *, run_id: str, token: str, api_url: str +) -> datetime: + jobs = _api_json( + f"{api_url}/repos/{REPOSITORY}/actions/runs/{quote(str(run_id))}/jobs?per_page=100", + token, + ) + rows = jobs.get("jobs") if isinstance(jobs, Mapping) else None + if ( + not isinstance(rows, list) or type(jobs.get("total_count")) is not int + or jobs["total_count"] > 100 + ): + raise _stop("parent_run_jobs_unverified") + deploy_rows = [row for row in rows if isinstance(row, Mapping) and row.get("name") == "deploy"] + if len(deploy_rows) != 1: + raise _stop("parent_deploy_unverified") + return _validate_terminal_deploy(deploy_rows[0]) + + +def _validate_terminal_deploy(deploy: Mapping[str, Any]) -> datetime: + steps = deploy.get("steps") + if ( + deploy.get("status") != "completed" or deploy.get("conclusion") != "success" + or not isinstance(steps, list) + ): + raise _stop("parent_deploy_unverified") + strategy = [step for step in steps if isinstance(step, Mapping) and step.get("name") == "4. Run trading strategy"] + report = [step for step in steps if isinstance(step, Mapping) and step.get("name") == "5. Stage execution report for isolated log publisher"] + if ( + len(strategy) != 1 or strategy[0].get("status") != "completed" + or strategy[0].get("conclusion") != "success" + or len(report) != 1 or report[0].get("status") != "completed" + or report[0].get("conclusion") != "success" + ): + raise _stop("parent_strategy_or_report_unverified") + deploy_started = _parse_github_time(deploy.get("started_at")) + strategy_started = _parse_github_time(strategy[0].get("started_at")) + strategy_finished = _parse_github_time(strategy[0].get("completed_at")) + report_started = _parse_github_time(report[0].get("started_at")) + report_finished = _parse_github_time(report[0].get("completed_at")) + if ( + strategy_started < deploy_started or strategy_finished < strategy_started + or report_started < strategy_finished or report_finished < report_started + ): + raise _stop("parent_run_jobs_unverified") + return strategy_finished + + def _ensure_no_other_active_parent_run( *, run_id: str, token: str, api_url: str ) -> None: @@ -355,31 +410,7 @@ def verify_parent_run( if len(deploy_jobs) != 1: raise _stop("parent_deploy_unverified") deploy = deploy_jobs[0] - steps = deploy.get("steps") - if ( - deploy.get("status") != "completed" or deploy.get("conclusion") != "success" - or not isinstance(steps, list) - ): - raise _stop("parent_deploy_unverified") - strategy_steps = [step for step in steps if isinstance(step, Mapping) and step.get("name") == "4. Run trading strategy"] - report_steps = [step for step in steps if isinstance(step, Mapping) and step.get("name") == "5. Stage execution report for isolated log publisher"] - if ( - len(strategy_steps) != 1 or strategy_steps[0].get("status") != "completed" - or strategy_steps[0].get("conclusion") != "success" - or len(report_steps) != 1 or report_steps[0].get("status") != "completed" - or report_steps[0].get("conclusion") != "success" - ): - raise _stop("parent_strategy_or_report_unverified") - deploy_started = _parse_github_time(deploy.get("started_at")) - strategy_started = _parse_github_time(strategy_steps[0].get("started_at")) - strategy_finished = _parse_github_time(strategy_steps[0].get("completed_at")) - report_started = _parse_github_time(report_steps[0].get("started_at")) - report_finished = _parse_github_time(report_steps[0].get("completed_at")) - if ( - strategy_started < deploy_started or strategy_finished < strategy_started - or report_started < strategy_finished or report_finished < report_started - ): - raise _stop("parent_run_jobs_unverified") + strategy_finished = _validate_terminal_deploy(deploy) # The protected setting and report binding identify the intended app, but # only the Runtime release-selection log proves what this parent ran. @@ -405,7 +436,6 @@ def verify_parent_run( artifact.get("expired") is not False or type(artifact.get("size_in_bytes")) is not int or artifact["size_in_bytes"] <= 0 or _parse_github_time(artifact.get("created_at")) < strategy_finished - or _parse_github_time(artifact.get("created_at")) < deploy_started ): raise _stop("trigger_report_artifact_missing") return {"run_id": str(run_id), "report_artifact": artifact_name} @@ -429,16 +459,18 @@ def verify_current_source_from_env(env: Mapping[str, str]) -> None: api_url=str(env.get("GITHUB_API_URL") or "https://api.github.com"), ) elif mode == "legacy_terminal": - verify_source_is_current( - run_id=str(env.get("SOURCE_RUN_ID") or ""), - token=str(env.get("GITHUB_TOKEN") or ""), - api_url=str(env.get("GITHUB_API_URL") or "https://api.github.com"), - ) + verify_terminal_source_from_env(env) else: raise _stop("source_mode_invalid") -def verify_trigger_run(*, run_id: str, repository: str, token: str, api_url: str) -> dict[str, str]: +def verify_trigger_run( + *, run_id: str, repository: str, token: str, api_url: str, + expected_workflow_sha: str = LEGACY_RUNTIME_WORKFLOW_SHA, + expected_ref: str | None = None, + require_attempt_one: bool = False, + require_strategy_steps: bool = False, +) -> dict[str, str]: """Validate source run metadata, selected application SHA, and report artifact.""" if repository != REPOSITORY or not re.fullmatch(r"[1-9][0-9]{0,19}", str(run_id)): raise _stop("trigger_identity_invalid") @@ -447,14 +479,18 @@ def verify_trigger_run(*, run_id: str, repository: str, token: str, api_url: str head_repo = run.get("head_repository") if isinstance(run, dict) else None base_repo = run.get("repository") if isinstance(run, dict) else None if ( - run.get("id") != int(run_id) + type(run.get("id")) is not int or run.get("id") != int(run_id) or run.get("name") != RUNTIME_WORKFLOW_NAME or str(run.get("path") or "").split("@", 1)[0] != ".github/workflows/main.yml" or run.get("event") != "workflow_dispatch" or run.get("status") != "completed" or run.get("conclusion") != "success" or run.get("head_branch") != RUNTIME_BRANCH - or run.get("head_sha") != LEGACY_RUNTIME_WORKFLOW_SHA + or run.get("head_sha") != expected_workflow_sha + or (require_attempt_one and ( + type(run.get("run_attempt")) is not int or run.get("run_attempt") != 1 + )) + or (expected_ref is not None and expected_ref != f"refs/heads/{RUNTIME_BRANCH}") or not isinstance(head_repo, Mapping) or head_repo.get("full_name") != REPOSITORY or not isinstance(base_repo, Mapping) @@ -463,6 +499,15 @@ def verify_trigger_run(*, run_id: str, repository: str, token: str, api_url: str raise _stop("trigger_identity_mismatch") _validate_release_log(str(run_id), token, api_url) _ensure_no_active_runtime_run(token=token, api_url=api_url) + _ensure_latest_runtime_run( + run_id=str(run_id), token=token, api_url=api_url, + expected_workflow_sha=expected_workflow_sha, + require_attempt_one=require_attempt_one, + ) + strategy_finished = ( + _validate_terminal_runtime_steps(run_id=str(run_id), token=token, api_url=api_url) + if require_strategy_steps else None + ) artifacts = _api_json( f"{api_url}/repos/{REPOSITORY}/actions/runs/{quote(str(run_id))}/artifacts?per_page=100", token, @@ -470,12 +515,52 @@ def verify_trigger_run(*, run_id: str, repository: str, token: str, api_url: str rows = artifacts.get("artifacts") if isinstance(artifacts, dict) else None expected_name = f"{EXPECTED_REPORT_ARTIFACT_PREFIX}{run_id}" matches = [row for row in rows if isinstance(row, Mapping) and row.get("name") == expected_name] if isinstance(rows, list) else [] - if len(matches) != 1 or matches[0].get("expired") is not False or matches[0].get("size_in_bytes", 0) <= 0: + if ( + len(matches) != 1 or matches[0].get("expired") is not False + or type(matches[0].get("size_in_bytes")) is not int or matches[0]["size_in_bytes"] <= 0 + or ( + strategy_finished is not None + and _parse_github_time(matches[0].get("created_at")) < strategy_finished + ) + ): raise _stop("trigger_report_artifact_missing") - verify_source_is_current(run_id=str(run_id), token=token, api_url=api_url) + _ensure_no_active_runtime_run(token=token, api_url=api_url) + _ensure_latest_runtime_run( + run_id=str(run_id), token=token, api_url=api_url, + expected_workflow_sha=expected_workflow_sha, + require_attempt_one=require_attempt_one, + ) return {"run_id": str(run_id), "report_artifact": expected_name} +def verify_terminal_source_from_env(env: Mapping[str, str]) -> dict[str, str]: + run_id = str(env.get("SOURCE_RUN_ID") or "") + repository = str(env.get("GITHUB_REPOSITORY") or "") + token = str(env.get("GITHUB_TOKEN") or "") + api_url = str(env.get("GITHUB_API_URL") or "https://api.github.com") + pinned_workflow_sha = str(env.get("BINANCE_RUNTIME_WORKFLOW_SHA") or "") + if repository != REPOSITORY or not re.fullmatch(r"[1-9][0-9]{0,19}", run_id): + raise _stop("trigger_identity_invalid") + if _GIT_SHA.fullmatch(pinned_workflow_sha): + run = _api_json( + f"{api_url}/repos/{REPOSITORY}/actions/runs/{quote(run_id)}", token + ) + if isinstance(run, Mapping) and run.get("head_sha") == pinned_workflow_sha: + return verify_trigger_run( + run_id=run_id, + repository=repository, + token=token, + api_url=api_url, + expected_workflow_sha=pinned_workflow_sha, + expected_ref=str(env.get("GITHUB_REF") or ""), + require_attempt_one=True, + require_strategy_steps=True, + ) + return verify_trigger_run( + run_id=run_id, repository=repository, token=token, api_url=api_url + ) + + def _valid_order_result_tree(value: Any, parent_key: str = "") -> bool: if isinstance(value, Mapping): for key, child in value.items(): @@ -850,12 +935,7 @@ def preflight(*, env: Mapping[str, str], output: Path) -> None: api_url=str(env.get("GITHUB_API_URL") or "https://api.github.com"), ) elif mode == "legacy_terminal": - result = verify_trigger_run( - run_id=str(env.get("SOURCE_RUN_ID") or ""), - repository=str(env.get("GITHUB_REPOSITORY") or ""), - token=str(env.get("GITHUB_TOKEN") or ""), - api_url=str(env.get("GITHUB_API_URL") or "https://api.github.com"), - ) + result = verify_terminal_source_from_env(env) else: raise _stop("source_mode_invalid") output.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index e6721ffb..d4ad731f 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -17,6 +17,7 @@ APP_SHA = "8cb56617115fa45028e34d788e71884b6a303d77" +PROTECTED_RUNTIME_SHA = "d" * 40 READER_SHA = "a" * 40 UID_DIGEST = "b" * 64 START = datetime(2026, 10, 2, 10, 0, tzinfo=timezone.utc) @@ -168,6 +169,114 @@ def _install_parent_release_log(monkeypatch, reader, selected_sha=APP_SHA): monkeypatch.setattr(reader, "_read_job_log_text", lambda *_args: log_text.encode()) +def _current_terminal_run_api(url, _token, mutation=None): + from urllib.parse import urlparse + + parsed = urlparse(url) + source_sha = "9cfcf0531d1ea176e6f26590cf15edbd31bd6567" if mutation == "legacy_old" else PROTECTED_RUNTIME_SHA + if "/actions/workflows/main.yml/runs?status=" in url: + return {"total_count": 0, "workflow_runs": []} + if parsed.path.endswith("/actions/workflows/main.yml/runs"): + run_id = 602 if mutation == "newer_terminal" else 601 + return {"total_count": 2, "workflow_runs": [{ + "id": run_id, "name": "Runtime", + "path": ".github/workflows/main.yml@refs/heads/runtime-production", + "event": "workflow_dispatch", "status": "completed", "conclusion": "success", + "head_branch": "runtime-production", "head_sha": source_sha, + "run_attempt": 1, "repository": {"full_name": "QuantStrategyLab/BinancePlatform"}, + }]} + if parsed.path.endswith("/actions/runs/601"): + attempt = 2 if mutation == "attempt_two" else 1 + return { + "id": 601, "run_attempt": attempt, "name": "Runtime", + "path": ".github/workflows/main.yml@refs/heads/runtime-production", + "event": "workflow_dispatch", "status": "completed", "conclusion": "success", + "head_branch": "runtime-production", "head_sha": source_sha, + "repository": {"full_name": "QuantStrategyLab/BinancePlatform"}, + "head_repository": {"full_name": "QuantStrategyLab/BinancePlatform"}, + } + if parsed.path.endswith("/actions/runs/601/jobs"): + upload_conclusion = "failure" if mutation == "failed_report" else "success" + return {"total_count": 1, "jobs": [{ + "id": 701, "name": "deploy", "status": "completed", "conclusion": "success", + "started_at": "2026-10-02T11:00:00Z", + "steps": [ + {"name": "Resolve approved runtime release SHA", "status": "completed", "conclusion": "success"}, + {"name": "4. Run trading strategy", "status": "completed", "conclusion": "success", "started_at": "2026-10-02T11:01:00Z", "completed_at": "2026-10-02T11:05:00Z"}, + {"name": "5. Stage execution report for isolated log publisher", "status": "completed", "conclusion": upload_conclusion, "started_at": "2026-10-02T11:05:01Z", "completed_at": "2026-10-02T11:06:00Z"}, + ], + }]} + if parsed.path.endswith("/actions/runs/601/artifacts"): + created_at = "2026-10-02T11:04:59Z" if mutation == "early_artifact" else "2026-10-02T11:05:10Z" + return {"artifacts": [{ + "name": "binance-execution-report-601", "expired": False, + "size_in_bytes": 123, "created_at": created_at, + }]} + raise AssertionError("unexpected synthetic GitHub API path") + + +@pytest.mark.parametrize( + "mutation,reason", + [ + ("wrong_application", "trigger_release_mismatch"), + ("attempt_two", "trigger_identity_mismatch"), + ("failed_report", "parent_strategy_or_report_unverified"), + ("early_artifact", "trigger_report_artifact_missing"), + ("newer_terminal", "runtime_run_not_latest_success"), + ], +) +def test_manual_terminal_accepts_only_current_pinned_runtime_and_8cb_app(monkeypatch, mutation, reason): + from scripts import read_binance_account_facts as reader + + monkeypatch.setattr(reader, "_api_json", lambda url, token: _current_terminal_run_api(url, token, mutation)) + _install_parent_release_log( + monkeypatch, reader, selected_sha="e" * 40 if mutation == "wrong_application" else APP_SHA + ) + env = { + "SOURCE_RUN_ID": "601", + "GITHUB_REPOSITORY": "QuantStrategyLab/BinancePlatform", + "GITHUB_REF": "refs/heads/runtime-production", + "BINANCE_RUNTIME_WORKFLOW_SHA": PROTECTED_RUNTIME_SHA, + "GITHUB_TOKEN": "synthetic-token", + "GITHUB_API_URL": "https://api.example", + } + if reason: + with pytest.raises(reader.ReaderError, match=reason): + reader.verify_terminal_source_from_env(env) + + +def test_manual_terminal_accepts_protected_sha_when_runtime_selected_8cb(monkeypatch): + from scripts import read_binance_account_facts as reader + + monkeypatch.setattr(reader, "_api_json", _current_terminal_run_api) + _install_parent_release_log(monkeypatch, reader) + result = reader.verify_terminal_source_from_env({ + "SOURCE_RUN_ID": "601", + "GITHUB_REPOSITORY": "QuantStrategyLab/BinancePlatform", + "GITHUB_REF": "refs/heads/runtime-production", + "BINANCE_RUNTIME_WORKFLOW_SHA": PROTECTED_RUNTIME_SHA, + "GITHUB_TOKEN": "synthetic-token", + "GITHUB_API_URL": "https://api.example", + }) + assert result == {"run_id": "601", "report_artifact": "binance-execution-report-601"} + + +def test_manual_terminal_keeps_fixed_legacy_9cfc_path(monkeypatch): + from scripts import read_binance_account_facts as reader + + monkeypatch.setattr(reader, "_api_json", lambda url, token: _current_terminal_run_api(url, token, "legacy_old")) + _install_parent_release_log(monkeypatch, reader) + result = reader.verify_terminal_source_from_env({ + "SOURCE_RUN_ID": "601", + "GITHUB_REPOSITORY": "QuantStrategyLab/BinancePlatform", + "GITHUB_REF": "refs/heads/runtime-production", + "BINANCE_RUNTIME_WORKFLOW_SHA": PROTECTED_RUNTIME_SHA, + "GITHUB_TOKEN": "synthetic-token", + "GITHUB_API_URL": "https://api.example", + }) + assert result == {"run_id": "601", "report_artifact": "binance-execution-report-601"} + + def test_same_parent_runtime_read_is_allowed_only_for_exact_in_progress_run(monkeypatch): from scripts import read_binance_account_facts as reader From 0ce49bcf75a81ba974368b4d398e396b2e4648e0 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:10:54 +0800 Subject: [PATCH 10/47] fix: install uv in isolated account facts job Co-Authored-By: Codex --- .github/workflows/binance-account-facts.yml | 3 +++ tests/test_binance_account_facts.py | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/.github/workflows/binance-account-facts.yml b/.github/workflows/binance-account-facts.yml index 5b03e650..f6ad64d5 100644 --- a/.github/workflows/binance-account-facts.yml +++ b/.github/workflows/binance-account-facts.yml @@ -59,6 +59,9 @@ jobs: ref: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION }} persist-credentials: false + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9 + - name: Verify trusted checkout and exact Runtime source id: preflight shell: bash diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index d4ad731f..702494ca 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -108,6 +108,18 @@ def test_source_binding_hash_uses_canonical_json(): def test_workflow_uses_fixed_protected_reader_revision_not_trigger_sha(): workflow = (Path(__file__).parents[1] / ".github" / "workflows" / "binance-account-facts.yml").read_text() + checkout = workflow.index(" - name: Checkout trusted default-branch reader source") + setup_uv = workflow.index(" - name: Set up uv") + preflight = workflow.index(" - name: Verify trusted checkout and exact Runtime source") + install = workflow.index("uv sync --frozen --no-dev") + + assert checkout < setup_uv < preflight < install + setup_uv_step = workflow[setup_uv:preflight] + assert "uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9" in setup_uv_step + assert "with:" not in setup_uv_step + assert "secrets." not in setup_uv_step + assert workflow.count("astral-sh/setup-uv@") == 1 + assert "ref: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION }}" in workflow assert 'BINANCE_ACCOUNT_FACTS_READER_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || \'\' }}' in workflow assert "READER_PUBLIC_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }}" in workflow From 30984affb4eec1c2e12c971a50943bc72d2e02b1 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:27:02 +0800 Subject: [PATCH 11/47] fix: use workflow identity instead of dynamic run title Co-Authored-By: Codex --- scripts/read_binance_account_facts.py | 6 +---- tests/test_binance_account_facts.py | 37 +++++++++++++++++++-------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/scripts/read_binance_account_facts.py b/scripts/read_binance_account_facts.py index ee530bbc..a4b9da03 100644 --- a/scripts/read_binance_account_facts.py +++ b/scripts/read_binance_account_facts.py @@ -35,7 +35,6 @@ REPOSITORY = "QuantStrategyLab/BinancePlatform" LEGACY_RUNTIME_WORKFLOW_SHA = "9cfcf0531d1ea176e6f26590cf15edbd31bd6567" APPROVED_APPLICATION_SHA = "8cb56617115fa45028e34d788e71884b6a303d77" -RUNTIME_WORKFLOW_NAME = "Runtime" RUNTIME_BRANCH = "runtime-production" EXPECTED_REPORT_ARTIFACT_PREFIX = "binance-execution-report-" _GIT_SHA = re.compile(r"[0-9a-f]{40}\Z") @@ -225,7 +224,6 @@ def _ensure_latest_runtime_run( row = rows[0] if ( str(row.get("id")) != str(run_id) - or row.get("name") != RUNTIME_WORKFLOW_NAME or str(row.get("path") or "").split("@", 1)[0] != ".github/workflows/main.yml" or row.get("event") != "workflow_dispatch" or row.get("status") != "completed" @@ -354,7 +352,6 @@ def _ensure_latest_parent_run(*, run_id: str, token: str, api_url: str) -> None: row = rows[0] if ( str(row.get("id")) != str(run_id) - or row.get("name") != RUNTIME_WORKFLOW_NAME or str(row.get("path") or "").split("@", 1)[0] != ".github/workflows/main.yml" or row.get("head_branch") != RUNTIME_BRANCH or row.get("status") != "in_progress" @@ -386,7 +383,6 @@ def verify_parent_run( not isinstance(run, Mapping) or type(run.get("id")) is not int or run.get("id") != int(run_id) or type(run.get("run_attempt")) is not int or run.get("run_attempt") != 1 - or run.get("name") != RUNTIME_WORKFLOW_NAME or str(run.get("path") or "").split("@", 1)[0] != ".github/workflows/main.yml" or run.get("event") != "workflow_dispatch" or run.get("status") != "in_progress" @@ -478,9 +474,9 @@ def verify_trigger_run( run = _api_json(url, token) head_repo = run.get("head_repository") if isinstance(run, dict) else None base_repo = run.get("repository") if isinstance(run, dict) else None + # GitHub's API name is the dynamic run-name display title, not workflow identity. if ( type(run.get("id")) is not int or run.get("id") != int(run_id) - or run.get("name") != RUNTIME_WORKFLOW_NAME or str(run.get("path") or "").split("@", 1)[0] != ".github/workflows/main.yml" or run.get("event") != "workflow_dispatch" or run.get("status") != "completed" diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 702494ca..09830852 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -142,13 +142,13 @@ def _parent_run_api(url, _token): return {"total_count": len(rows), "workflow_runs": rows} if parsed.path.endswith("/actions/workflows/main.yml/runs"): return {"total_count": 1, "workflow_runs": [{ - "id": 501, "name": "Runtime", + "id": 501, "name": "Runtime · strategy", "path": ".github/workflows/main.yml@refs/heads/runtime-production", "head_branch": "runtime-production", "status": "in_progress", }]} if parsed.path.endswith("/actions/runs/501"): return { - "id": 501, "run_attempt": 1, "name": "Runtime", + "id": 501, "run_attempt": 1, "name": "Runtime · strategy", "path": ".github/workflows/main.yml@refs/heads/runtime-production", "event": "workflow_dispatch", "status": "in_progress", "head_branch": "runtime-production", "head_sha": "d" * 40, @@ -185,27 +185,39 @@ def _current_terminal_run_api(url, _token, mutation=None): from urllib.parse import urlparse parsed = urlparse(url) - source_sha = "9cfcf0531d1ea176e6f26590cf15edbd31bd6567" if mutation == "legacy_old" else PROTECTED_RUNTIME_SHA + source_sha = ( + "9cfcf0531d1ea176e6f26590cf15edbd31bd6567" + if mutation == "legacy_old" + else "e" * 40 if mutation == "wrong_workflow_sha" else PROTECTED_RUNTIME_SHA + ) + source_path = ( + ".github/workflows/other.yml@refs/heads/runtime-production" + if mutation == "wrong_path" + else ".github/workflows/main.yml@refs/heads/runtime-production" + ) + source_repository = ( + "other/repository" if mutation == "wrong_repository" else "QuantStrategyLab/BinancePlatform" + ) if "/actions/workflows/main.yml/runs?status=" in url: return {"total_count": 0, "workflow_runs": []} if parsed.path.endswith("/actions/workflows/main.yml/runs"): run_id = 602 if mutation == "newer_terminal" else 601 return {"total_count": 2, "workflow_runs": [{ - "id": run_id, "name": "Runtime", - "path": ".github/workflows/main.yml@refs/heads/runtime-production", + "id": run_id, "name": "Runtime · strategy", + "path": source_path, "event": "workflow_dispatch", "status": "completed", "conclusion": "success", "head_branch": "runtime-production", "head_sha": source_sha, - "run_attempt": 1, "repository": {"full_name": "QuantStrategyLab/BinancePlatform"}, + "run_attempt": 1, "repository": {"full_name": source_repository}, }]} if parsed.path.endswith("/actions/runs/601"): attempt = 2 if mutation == "attempt_two" else 1 return { - "id": 601, "run_attempt": attempt, "name": "Runtime", - "path": ".github/workflows/main.yml@refs/heads/runtime-production", + "id": 601, "run_attempt": attempt, "name": "Runtime · strategy", + "path": source_path, "event": "workflow_dispatch", "status": "completed", "conclusion": "success", "head_branch": "runtime-production", "head_sha": source_sha, - "repository": {"full_name": "QuantStrategyLab/BinancePlatform"}, - "head_repository": {"full_name": "QuantStrategyLab/BinancePlatform"}, + "repository": {"full_name": source_repository}, + "head_repository": {"full_name": source_repository}, } if parsed.path.endswith("/actions/runs/601/jobs"): upload_conclusion = "failure" if mutation == "failed_report" else "success" @@ -232,6 +244,9 @@ def _current_terminal_run_api(url, _token, mutation=None): [ ("wrong_application", "trigger_release_mismatch"), ("attempt_two", "trigger_identity_mismatch"), + ("wrong_path", "trigger_identity_mismatch"), + ("wrong_repository", "trigger_identity_mismatch"), + ("wrong_workflow_sha", "trigger_identity_mismatch"), ("failed_report", "parent_strategy_or_report_unverified"), ("early_artifact", "trigger_report_artifact_missing"), ("newer_terminal", "runtime_run_not_latest_success"), @@ -593,7 +608,7 @@ def test_manual_run_must_still_be_the_latest_qualifying_runtime(monkeypatch): lambda _url, _token: { "workflow_runs": [{ "id": 200, - "name": "Runtime", + "name": "Runtime · strategy", "path": ".github/workflows/main.yml@refs/heads/runtime-production", "event": "workflow_dispatch", "status": "completed", From 4ea1cb3b50c2536d46afd20747dabbadc5bcbf71 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:29:10 +0800 Subject: [PATCH 12/47] fix: qualify read-only identity and serialized reports Co-Authored-By: Codex --- scripts/read_binance_account_facts.py | 5 +-- tests/test_binance_account_facts.py | 63 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/scripts/read_binance_account_facts.py b/scripts/read_binance_account_facts.py index a4b9da03..96f265ea 100644 --- a/scripts/read_binance_account_facts.py +++ b/scripts/read_binance_account_facts.py @@ -614,8 +614,7 @@ def validate_strategy_report(report: Mapping[str, Any], runtime_target: Mapping[ or not isinstance(report.get("run_id"), str) or not report["run_id"].strip() or report.get("execution_blocked_reason") - or not isinstance(errors, Mapping) - or errors.get("errors") != [] + or errors not in ({}, {"errors": []}) or not isinstance(side_effects, Mapping) or type(side_effects.get("executed_call_count")) is not int or side_effects.get("executed_call_count") < 0 @@ -700,7 +699,6 @@ def _target_identity(raw: str) -> dict[str, Any]: raise _stop("runtime_target_invalid") try: from quant_platform_kit.common.runtime_target import resolve_runtime_target_from_env - from quant_platform_kit.common.live_continuity import runtime_target_permits_standard_execution target = resolve_runtime_target_from_env( env={"RUNTIME_TARGET_JSON": raw}, expected_platform_id="binance" @@ -710,7 +708,6 @@ def _target_identity(raw: str) -> dict[str, Any]: value = target.to_dict() if ( target.dry_run_only - or not runtime_target_permits_standard_execution(target) or not isinstance(value.get("account_scope"), str) or not value["account_scope"] or value["account_scope"] == "default" diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 09830852..1db1f097 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -662,6 +662,35 @@ def test_runtime_target_and_protected_binding_match_canonical_selector_shape(): assert reader._binding(json.dumps(binding), target, UID_DIGEST, READER_SHA) == binding +def test_runtime_target_accepts_reconcile_only_with_valid_continuity_fingerprint(): + from quant_platform_kit.common.live_continuity import runtime_target_fingerprint + from scripts import read_binance_account_facts as reader + + target = { + "platform_id": "binance", + "strategy_profile": "synthetic-profile", + "dry_run_only": False, + "deployment_selector": "synthetic-deployment", + "account_selector": ["synthetic-account"], + "account_scope": "synthetic-scope", + "service_name": "synthetic-service", + } + continuity = { + "state": "RECONCILE_ONLY", + "baseline_kind": "legacy_authorized", + "baseline_id": "synthetic-baseline", + "baseline_target_sha256": runtime_target_fingerprint(target), + "captured_at": "2026-10-02", + } + target["live_continuity"] = continuity + resolved = reader._target_identity(json.dumps(target)) + assert resolved["live_continuity"]["state"] == "RECONCILE_ONLY" + + target["live_continuity"] = {**continuity, "baseline_target_sha256": "0" * 64} + with pytest.raises(reader.ReaderError, match="runtime_target_invalid"): + reader._target_identity(json.dumps(target)) + + def _eligible_strategy_report(*, receipt_outcome="no_action", confirmation=None, observation=None): from quant_platform_kit.common.execution_receipts import build_execution_receipt @@ -717,6 +746,40 @@ def test_strategy_report_accepts_closed_execution_receipt(): reader.validate_strategy_report(report, target) +@pytest.mark.parametrize("error_summary", [{}, {"errors": []}]) +def test_strategy_report_accepts_only_exact_empty_error_summary_shapes(error_summary): + from scripts import read_binance_account_facts as reader + + report, target = _eligible_strategy_report() + report["error_summary"] = error_summary + reader.validate_strategy_report(report, target) + + +@pytest.mark.parametrize("error_summary", [ + None, + [], + {"errors": None}, + {"errors": ["synthetic-error"]}, + {"errors": [], "extra": "synthetic"}, +]) +def test_strategy_report_rejects_invalid_or_open_error_summary_shapes(error_summary): + from scripts import read_binance_account_facts as reader + + report, target = _eligible_strategy_report() + report["error_summary"] = error_summary + with pytest.raises(reader.ReaderError, match="strategy_report_not_eligible"): + reader.validate_strategy_report(report, target) + + +def test_strategy_report_rejects_missing_error_summary(): + from scripts import read_binance_account_facts as reader + + report, target = _eligible_strategy_report() + del report["error_summary"] + with pytest.raises(reader.ReaderError, match="strategy_report_not_eligible"): + reader.validate_strategy_report(report, target) + + def test_strategy_report_accepts_missing_receipt_only_for_explicit_zero_counters(): from scripts import read_binance_account_facts as reader From bf5f731f9e737194f6c09bde22f88d1bd64929bf Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:00:37 +0800 Subject: [PATCH 13/47] fix: distinguish private-safe spot response failures (#329) Co-authored-by: Codex --- application/account_facts.py | 24 ++++++++++++++---------- tests/test_binance_account_facts.py | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/application/account_facts.py b/application/account_facts.py index 2b1d3005..4c61b9ff 100644 --- a/application/account_facts.py +++ b/application/account_facts.py @@ -167,7 +167,7 @@ def collect_account_facts( except Exception: raise _fail("spot_read_failed") from None if not isinstance(account, Mapping): - raise _fail("spot_response_invalid") + raise _fail("spot_response_not_object") uid = account.get("uid") if isinstance(uid, bool) or not isinstance(uid, (str, int)) or not str(uid): raise _fail("account_identity_missing") @@ -178,19 +178,23 @@ def collect_account_facts( raise _fail("account_identity_mismatch") balances = account.get("balances") - if not isinstance(balances, list) or len(balances) > 5000: - raise _fail("spot_response_invalid") + if not isinstance(balances, list): + raise _fail("spot_balances_invalid") + if len(balances) > 5000: + raise _fail("spot_balances_limit_exceeded") assets: dict[str, dict[str, Decimal]] = {} for row in balances: if not isinstance(row, Mapping): - raise _fail("spot_response_invalid") + raise _fail("spot_balance_row_invalid") asset = row.get("asset") - if ( - not isinstance(asset, str) - or not _ASSET.fullmatch(asset) - or asset in assets - ): - raise _fail("spot_response_invalid") + if not isinstance(asset, str): + raise _fail("spot_asset_type_invalid") + if not asset.isascii(): + raise _fail("spot_asset_non_ascii") + if not _ASSET.fullmatch(asset): + raise _fail("spot_asset_format_invalid") + if asset in assets: + raise _fail("spot_asset_duplicate") assets[asset] = { "spot_free": _decimal(row.get("free")), "spot_locked": _decimal(row.get("locked")), diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 1db1f097..4680bdfa 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -439,6 +439,32 @@ def get_account(self): assert client.calls == [("spot",)] +@pytest.mark.parametrize(("account", "reason"), [ + (None, "spot_response_not_object"), + ({"uid": "123456", "balances": None}, "spot_balances_invalid"), + ({"uid": "123456", "balances": [{}] * 5001}, "spot_balances_limit_exceeded"), + ({"uid": "123456", "balances": ["synthetic-private-row"]}, "spot_balance_row_invalid"), + ({"uid": "123456", "balances": [{"asset": 17}]}, "spot_asset_type_invalid"), + ({"uid": "123456", "balances": [{"asset": "synthetic-bad-symbol"}]}, "spot_asset_format_invalid"), + ({"uid": "123456", "balances": [{"asset": "币"}]}, "spot_asset_non_ascii"), + ({"uid": "123456", "balances": [ + {"asset": "SYNTHETIC", "free": "0", "locked": "0"}, + {"asset": "SYNTHETIC", "free": "0", "locked": "0"}, + ]}, "spot_asset_duplicate"), +]) +def test_spot_validation_uses_fixed_reason_without_echoing_response_values(account, reason): + class InvalidSpot(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return account + + client = InvalidSpot() + with pytest.raises(AccountFactsUnavailable) as raised: + _collect(client) + assert str(raised.value) == f"binance_account_facts_{reason}" + assert client.calls == [("spot",)] + + def test_rejects_earn_asset_without_an_explicit_spot_balance_row(): class SpotWithoutEarnAsset(FakeBinance): def get_account(self): From 12e225039d0469a3e37b27f202a940aae8775605 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:20:45 +0800 Subject: [PATCH 14/47] fix: preserve valid Unicode wallet asset codes (#330) Co-authored-by: Codex --- application/account_facts.py | 24 +++++--- tests/test_binance_account_facts.py | 86 ++++++++++++++++++++++++++++- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/application/account_facts.py b/application/account_facts.py index 4c61b9ff..5f138333 100644 --- a/application/account_facts.py +++ b/application/account_facts.py @@ -10,6 +10,7 @@ import hashlib import json import re +import unicodedata from collections.abc import Mapping from datetime import datetime, timezone from decimal import Decimal, InvalidOperation, localcontext @@ -28,7 +29,19 @@ MAX_EARN_POSITIONS = 10_000 _HEX_SHA256 = re.compile(r"[0-9a-f]{64}\Z") _GIT_SHA = re.compile(r"[0-9a-f]{40}\Z") -_ASSET = re.compile(r"[A-Z0-9]{1,128}\Z") +_HANGUL_FILLERS = frozenset("\u115f\u1160\u3164\uffa0") + + +def _valid_asset(value: Any) -> bool: + if not isinstance(value, str) or not 1 <= len(value) <= 128: + return False + for character in value: + if character.isascii(): + if not ("A" <= character <= "Z" or "0" <= character <= "9"): + return False + elif character in _HANGUL_FILLERS or unicodedata.category(character)[0] not in {"L", "N"}: + return False + return True class AccountFactsUnavailable(ValueError): @@ -189,9 +202,7 @@ def collect_account_facts( asset = row.get("asset") if not isinstance(asset, str): raise _fail("spot_asset_type_invalid") - if not asset.isascii(): - raise _fail("spot_asset_non_ascii") - if not _ASSET.fullmatch(asset): + if not _valid_asset(asset): raise _fail("spot_asset_format_invalid") if asset in assets: raise _fail("spot_asset_duplicate") @@ -237,8 +248,7 @@ def collect_account_facts( asset = row.get("asset") product_id = row.get("productId") if ( - not isinstance(asset, str) - or not _ASSET.fullmatch(asset) + not _valid_asset(asset) or not isinstance(product_id, str) or not product_id or product_id in seen_products @@ -383,7 +393,7 @@ def validate_account_facts_payload(payload: Any) -> dict[str, Any]: }: raise _fail("payload_invalid") asset = row.get("asset") - if not isinstance(asset, str) or not _ASSET.fullmatch(asset) or asset in seen_assets: + if not _valid_asset(asset) or asset in seen_assets: raise _fail("payload_invalid") seen_assets.add(asset) amount_texts = [row.get(name) for name in ("spot_free", "spot_locked", "flexible_earn", "quantity")] diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 4680bdfa..edca82b1 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -446,7 +446,7 @@ def get_account(self): ({"uid": "123456", "balances": ["synthetic-private-row"]}, "spot_balance_row_invalid"), ({"uid": "123456", "balances": [{"asset": 17}]}, "spot_asset_type_invalid"), ({"uid": "123456", "balances": [{"asset": "synthetic-bad-symbol"}]}, "spot_asset_format_invalid"), - ({"uid": "123456", "balances": [{"asset": "币"}]}, "spot_asset_non_ascii"), + ({"uid": "123456", "balances": [{"asset": "A\u200b"}]}, "spot_asset_format_invalid"), ({"uid": "123456", "balances": [ {"asset": "SYNTHETIC", "free": "0", "locked": "0"}, {"asset": "SYNTHETIC", "free": "0", "locked": "0"}, @@ -465,6 +465,90 @@ def get_account(self): assert client.calls == [("spot",)] +@pytest.mark.parametrize("asset", ["币", "12", "𐐀", "A" * 128, "汉" * 128]) +def test_unicode_asset_rules_preserve_spot_earn_and_payload_values(asset): + class UnicodeAsset(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return { + "uid": "123456", + "balances": [{"asset": asset, "free": "0.5", "locked": "0"}], + } + + def get_simple_earn_flexible_product_position(self, *, current, size): + self.calls.append(("earn", current, size)) + return { + "rows": [{"asset": asset, "productId": "synthetic-product", "totalAmount": "0.25"}], + "total": 1, + } + + client = UnicodeAsset() + payload = _collect(client) + assert payload["assets"] == [{ + "asset": asset, + "quantity": "0.75", + "spot_free": "0.5", + "spot_locked": "0", + "flexible_earn": "0.25", + }] + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + round_tripped = json.loads(encoded) + assert round_tripped["assets"][0]["asset"] == asset + validate_account_facts_payload(round_tripped) + + +@pytest.mark.parametrize("asset", [ + "", " ", "btc", "A-B", "A\u0301", "A\u200b", "A\u202e", "A\ud800", "😀", "A" * 129, + "A\u115f", "A\u1160", "A\u3164", "A\uffa0", +]) +def test_unicode_asset_rules_reject_unsafe_or_out_of_contract_values(asset): + class InvalidAsset(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return { + "uid": "123456", + "balances": [{"asset": asset, "free": "0.5", "locked": "0"}], + } + + with pytest.raises(AccountFactsUnavailable, match="spot_asset_format_invalid"): + _collect(InvalidAsset()) + + +@pytest.mark.parametrize("asset", ["A\u115f", "A\u1160", "A\u3164", "A\uffa0"]) +def test_unicode_earn_asset_rejects_invisible_hangul_fillers(asset): + client = FakeBinance(earn_pages=[{ + "rows": [{"asset": asset, "productId": "synthetic-product", "totalAmount": "0.25"}], + "total": 1, + }]) + with pytest.raises(AccountFactsUnavailable, match="flexible_earn_page_invalid"): + _collect(client) + assert client.calls == [("spot",), ("earn", 1, 100)] + + +@pytest.mark.parametrize("asset", ["A\u115f", "A\u1160", "A\u3164", "A\uffa0"]) +def test_payload_validator_rejects_invisible_hangul_fillers(asset): + payload = _collect() + payload["assets"][0]["asset"] = asset + with pytest.raises(AccountFactsUnavailable, match="payload_invalid"): + validate_account_facts_payload(payload) + + +def test_exact_duplicate_unicode_asset_is_rejected_without_normalization(): + class DuplicateUnicodeAsset(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return { + "uid": "123456", + "balances": [ + {"asset": "币", "free": "0.5", "locked": "0"}, + {"asset": "币", "free": "0.5", "locked": "0"}, + ], + } + + with pytest.raises(AccountFactsUnavailable, match="spot_asset_duplicate"): + _collect(DuplicateUnicodeAsset()) + + def test_rejects_earn_asset_without_an_explicit_spot_balance_row(): class SpotWithoutEarnAsset(FakeBinance): def get_account(self): From d9fe47132d5edc4efa63982a98d3b3bf3b3b108f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:53:16 +0800 Subject: [PATCH 15/47] Classify private-safe account-facts publish failures (#331) * fix: preserve valid Unicode wallet asset codes Co-Authored-By: Codex * Classify private-safe account-facts publishing failures Co-Authored-By: Codex --------- Co-authored-by: Codex --- scripts/publish_binance_account_facts.py | 53 ++++++- tests/test_binance_account_facts.py | 179 +++++++++++++++++++++++ 2 files changed, 229 insertions(+), 3 deletions(-) diff --git a/scripts/publish_binance_account_facts.py b/scripts/publish_binance_account_facts.py index 936a7795..86dde13a 100644 --- a/scripts/publish_binance_account_facts.py +++ b/scripts/publish_binance_account_facts.py @@ -17,6 +17,24 @@ QRS_ENDPOINT = "https://qsl-strategy-switch-console.pigbibi.workers.dev/api/internal/binance-account-facts" +_HTTP_ERROR_LABELS = { + (400, "invalid_binance_account_facts"): "http_400_report_invalid", + (400, "invalid_binance_account_facts_time"): "http_400_report_time_invalid", + (400, "invalid_binance_account_facts_quantity"): "http_400_report_quantity_invalid", + (400, "invalid_binance_account_facts_assets"): "http_400_report_assets_invalid", + (401, "binance_account_facts_token_invalid"): "http_401_token_invalid", + (409, "binance_account_facts_binding_unmatched"): "http_409_binding_unmatched", + (409, "binance_account_facts_identity_mismatch"): "http_409_identity_mismatch", + (409, "binance_account_facts_observation_invalid"): "http_409_observation_invalid", + (409, "binance_account_facts_observation_conflict"): "http_409_observation_conflict", + (413, "binance_account_facts_payload_too_large"): "http_413_payload_too_large", + (503, "binance_account_facts_token_unavailable"): "http_503_receiver_unavailable", + (503, "binance_account_facts_binding_missing"): "http_503_receiver_unavailable", + (503, "binance_account_facts_binding_invalid"): "http_503_receiver_unavailable", + (503, "binance_account_facts_storage_unavailable"): "http_503_receiver_unavailable", + (503, "binance_account_facts_unavailable"): "http_503_receiver_unavailable", +} +_HTTP_REJECTED = "account_facts_publish_http_rejected" class PublishError(ValueError): @@ -28,6 +46,23 @@ def redirect_request(self, request, response, code, message, headers, new_url): return None +def _http_failure_code(error: HTTPError) -> str: + try: + raw = error.read(4097) + if not isinstance(raw, bytes) or len(raw) > 4096: + return _HTTP_REJECTED + body = json.loads(raw.decode("utf-8")) + if (not isinstance(body, dict) or set(body) != {"ok", "error"} + or body.get("ok") is not False or not isinstance(body.get("error"), str)): + return _HTTP_REJECTED + label = _HTTP_ERROR_LABELS.get((error.code, body["error"])) + if label is None: + return _HTTP_REJECTED + return f"account_facts_publish_{label}" + except (UnicodeError, ValueError, TypeError, RecursionError): + return _HTTP_REJECTED + + def publish_account_facts(*, facts_path: Path, env) -> str: if env.get("BINANCE_ACCOUNT_FACTS_ENABLED") != "true": raise PublishError("account_facts_publish_disabled") @@ -60,12 +95,24 @@ def publish_account_facts(*, facts_path: Path, env) -> str: raise PublishError("account_facts_publish_rejected") raw_ack = response.read(4097) if len(raw_ack) > 4096: - raise PublishError("account_facts_publish_rejected") + raise PublishError("account_facts_publish_ack_invalid") ack = json.loads(raw_ack.decode("utf-8")) except PublishError: raise - except (HTTPError, URLError, OSError, TimeoutError, UnicodeError, json.JSONDecodeError): - raise PublishError("account_facts_publish_failed") from None + except HTTPError as error: + try: + failure_code = _http_failure_code(error) + except TimeoutError: + raise PublishError("account_facts_publish_timeout") from None + except (URLError, OSError): + raise PublishError("account_facts_publish_network_failed") from None + raise PublishError(failure_code) from None + except TimeoutError: + raise PublishError("account_facts_publish_timeout") from None + except (URLError, OSError): + raise PublishError("account_facts_publish_network_failed") from None + except (UnicodeError, ValueError, TypeError, AttributeError, RecursionError): + raise PublishError("account_facts_publish_ack_invalid") from None if not isinstance(ack, dict) or ack.get("status") not in {"published", "unchanged"}: raise PublishError("account_facts_publish_rejected") return ack["status"] diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index edca82b1..02f843b0 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -4,6 +4,7 @@ import json from datetime import datetime, timedelta, timezone from pathlib import Path +from urllib.error import URLError import pytest @@ -1095,3 +1096,181 @@ def open(self, _request, timeout): "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", }, ) + + +@pytest.mark.parametrize(("status", "receiver_code", "safe_label"), [ + (400, "invalid_binance_account_facts_assets", "http_400_report_assets_invalid"), + (401, "binance_account_facts_token_invalid", "http_401_token_invalid"), + (409, "binance_account_facts_observation_conflict", "http_409_observation_conflict"), + (413, "binance_account_facts_payload_too_large", "http_413_payload_too_large"), + (503, "binance_account_facts_binding_missing", "http_503_receiver_unavailable"), +]) +def test_publisher_classifies_only_known_http_error_pairs_without_leaking_body( + tmp_path, monkeypatch, status, receiver_code, safe_label +): + from io import BytesIO + from urllib.error import HTTPError + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + secret = "synthetic-sensitive-placeholder" + private_url = f"https://private.example/path?token={secret}" + reads = [] + + class Opener: + def open(self, _request, timeout): + assert timeout == 20 + error = HTTPError( + private_url, + status, + "private exception detail", + hdrs=None, + fp=BytesIO(json.dumps({"ok": False, "error": receiver_code}).encode()), + ) + original_read = error.read + + def tracked_read(amount=None): + reads.append(amount) + return original_read(amount) + + error.read = tracked_read + raise error + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError) as raised: + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", + }, + ) + assert str(raised.value) == f"account_facts_publish_{safe_label}" + assert receiver_code not in str(raised.value) + assert secret not in str(raised.value) + assert "private.example" not in str(raised.value) + assert reads == [4097] + + +@pytest.mark.parametrize(("status", "body"), [ + (400, b"{\"ok\":false,\"error\":\"unknown-private-code\",\"private\":\"SENSITIVE\"}"), + (418, b"SENSITIVE private body"), + (409, b"x" * 4097), +]) +def test_publisher_rejects_unknown_or_oversized_http_error_body_closed( + tmp_path, monkeypatch, status, body +): + from io import BytesIO + from urllib.error import HTTPError + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + reads = [] + + class Opener: + def open(self, _request, timeout): + error = HTTPError( + "https://private.example/sensitive-path", + status, + "private exception detail", + hdrs=None, + fp=BytesIO(body), + ) + original_read = error.read + + def tracked_read(amount=None): + reads.append(amount) + return original_read(amount) + + error.read = tracked_read + raise error + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError) as raised: + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", + }, + ) + assert str(raised.value) == "account_facts_publish_http_rejected" + assert "SENSITIVE" not in str(raised.value) + assert "private.example" not in str(raised.value) + assert reads == [4097] + + +@pytest.mark.parametrize(("failure", "reason"), [ + (lambda: URLError("private network detail"), "account_facts_publish_network_failed"), + (lambda: OSError("private socket detail"), "account_facts_publish_network_failed"), + (lambda: TimeoutError("private timeout detail"), "account_facts_publish_timeout"), +]) +def test_publisher_classifies_transport_failures_without_retry_or_leak( + tmp_path, monkeypatch, failure, reason +): + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + calls = [] + + class Opener: + def open(self, _request, timeout): + calls.append(timeout) + raise failure() + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError) as raised: + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-secret", + }, + ) + assert str(raised.value) == reason + assert "private" not in str(raised.value) + assert "synthetic-secret" not in str(raised.value) + assert calls == [20] + + +@pytest.mark.parametrize("ack", [b"not-json", b"\xff\xfe"]) +def test_publisher_classifies_invalid_ack_without_leaking_or_retrying(tmp_path, monkeypatch, ack): + from scripts import publish_binance_account_facts as publisher + + facts_path = tmp_path / "facts.json" + facts_path.write_text(json.dumps(_collect()), encoding="utf-8") + calls = [] + + class Response: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, limit): + assert limit == 4097 + return ack + + class Opener: + def open(self, _request, timeout): + calls.append(timeout) + return Response() + + monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + with pytest.raises(publisher.PublishError) as raised: + publisher.publish_account_facts( + facts_path=facts_path, + env={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-secret", + }, + ) + assert str(raised.value) == "account_facts_publish_ack_invalid" + assert "synthetic-secret" not in str(raised.value) + assert calls == [20] From ab4f4155145a27d26afea3e627cdeac87bf4d58c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:42:22 +0800 Subject: [PATCH 16/47] Read receiver readiness from the existing protected runner (#332) * fix: preserve valid Unicode wallet asset codes Co-Authored-By: Codex * Classify private-safe account-facts publishing failures Co-Authored-By: Codex * Diagnose account facts receiver from the existing runner with one readonly request Co-Authored-By: Codex --------- Co-authored-by: Codex --- .../diagnose-account-facts-receiver.yml | 48 +++++ ...diagnose_binance_account_facts_receiver.py | 170 ++++++++++++++++++ scripts/publish_binance_account_facts.py | 31 ++-- tests/test_binance_account_facts.py | 161 ++++++++++++++++- 4 files changed, 393 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/diagnose-account-facts-receiver.yml create mode 100644 scripts/diagnose_binance_account_facts_receiver.py diff --git a/.github/workflows/diagnose-account-facts-receiver.yml b/.github/workflows/diagnose-account-facts-receiver.yml new file mode 100644 index 00000000..148154e7 --- /dev/null +++ b/.github/workflows/diagnose-account-facts-receiver.yml @@ -0,0 +1,48 @@ +name: Diagnose Binance Account Facts Receiver + +"on": + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: binance-account-facts-readonly + cancel-in-progress: false + +jobs: + readiness: + if: github.event_name == 'workflow_dispatch' && github.repository == 'QuantStrategyLab/BinancePlatform' && github.ref == 'refs/heads/main' + runs-on: self-hosted + timeout-minutes: 2 + environment: binance-runtime + steps: + - name: Checkout the exact dispatch revision + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Require the exact current main revision + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" != "workflow_dispatch" || \ + "$GITHUB_REPOSITORY" != "QuantStrategyLab/BinancePlatform" || \ + "$GITHUB_REF" != "refs/heads/main" || \ + ! "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ || \ + "$(git rev-parse HEAD)" != "$GITHUB_SHA" ]]; then + echo "receiver_diagnosis_source_unqualified" >&2 + exit 1 + fi + current_main_sha="$(git ls-remote https://github.com/QuantStrategyLab/BinancePlatform.git refs/heads/main 2>/dev/null | awk 'NF == 2 && $2 == "refs/heads/main" { print $1 }')" + if [[ ! "$current_main_sha" =~ ^[0-9a-f]{40}$ || "$current_main_sha" != "$GITHUB_SHA" ]]; then + echo "receiver_diagnosis_main_sha_mismatch" >&2 + exit 1 + fi + + - name: Read receiver readiness once + shell: bash + env: + BINANCE_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN }} + run: python3 scripts/diagnose_binance_account_facts_receiver.py diff --git a/scripts/diagnose_binance_account_facts_receiver.py b/scripts/diagnose_binance_account_facts_receiver.py new file mode 100644 index 00000000..7401fbc4 --- /dev/null +++ b/scripts/diagnose_binance_account_facts_receiver.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Make one authenticated, read-only readiness request to the QRS receiver.""" +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from urllib.error import HTTPError +from urllib.request import HTTPRedirectHandler, Request, build_opener + +QRS_ENDPOINT = "https://qsl-strategy-switch-console.pigbibi.workers.dev/api/internal/binance-account-facts" + +_READINESS_FIELDS = {"ok", "ready", "binding_valid", "account_options_readable", "unique_match"} +_READINESS_ERROR_FIELDS = _READINESS_FIELDS | {"error"} +_TOKEN_ERROR_CODES = {"binance_account_facts_token_invalid"} +_BINDING_ERROR_CODES = {"binance_account_facts_binding_unmatched"} +_CONFIG_ERROR_CODES = { + "binance_account_facts_token_unavailable", + "binance_account_facts_binding_missing", + "binance_account_facts_binding_invalid", +} +_READINESS_ERROR_CODES = _TOKEN_ERROR_CODES | _BINDING_ERROR_CODES | _CONFIG_ERROR_CODES | { + "binance_account_facts_account_options_unavailable", + "binance_account_facts_unavailable", +} +_MAX_BODY_BYTES = 4096 + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, new_url): + return None + + +@dataclass(frozen=True) +class ReceiverDiagnosis: + category: str + http_status: int | None = None + body_shape: str = "none" + receiver_error: str | None = None + cloudflare_code: int | None = None + + +def _json_body_shape(raw: bytes, headers) -> tuple[str, str | None, int | None]: + if len(raw) > _MAX_BODY_BYTES: + return "oversized", None, None + if not raw: + return "empty", None, None + try: + def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON key") + result[key] = value + return result + + body = json.loads( + raw.decode("utf-8"), + object_pairs_hook=unique_object, + parse_constant=lambda _value: (_ for _ in ()).throw(ValueError("invalid JSON constant")), + ) + except (UnicodeError, ValueError, RecursionError): + return "invalid_json", None, None + if not isinstance(body, dict): + return "json_non_object", None, None + + if (set(body) == _READINESS_FIELDS + and all(type(body.get(key)) is bool for key in _READINESS_FIELDS)): + if all(body[key] is True for key in _READINESS_FIELDS): + return "readiness", None, None + return "readiness_not_ready", None, None + + if (set(body) == _READINESS_ERROR_FIELDS + and all(type(body.get(key)) is bool for key in _READINESS_FIELDS) + and body.get("ok") is False and body.get("ready") is False + and isinstance(body.get("error"), str) + and body["error"] in _READINESS_ERROR_CODES): + error = body["error"] + if error in _TOKEN_ERROR_CODES: + return "receiver_error", "token_invalid", None + if error in _BINDING_ERROR_CODES: + return "receiver_error", "binding_unmatched", None + if error in _CONFIG_ERROR_CODES: + return "receiver_error", "config_or_binding", None + if error == "binance_account_facts_account_options_unavailable": + return "receiver_error", "account_options_unavailable", None + return "receiver_error", "receiver_unavailable", None + + if (set(body) == {"ok", "error"} and body.get("ok") is False + and isinstance(body.get("error"), str) + and body["error"] in _TOKEN_ERROR_CODES): + return "token_error", "token_invalid", None + + cf_code = _cloudflare_error_code(body, headers) + if cf_code is not None: + return "cloudflare_error", None, cf_code + return "json_object_other", None, None + + +def _cloudflare_error_code(body: dict, headers) -> int | None: + """Expose only the numeric code from the standard Cloudflare error envelope.""" + if str(headers.get("Server", "")).lower() != "cloudflare": + return None + if set(body) != {"success", "errors", "messages", "result"}: + return None + if body.get("success") is not False or body.get("messages") != [] or body.get("result") is not None: + return None + errors = body.get("errors") + if not isinstance(errors, list) or len(errors) != 1 or not isinstance(errors[0], dict): + return None + error = errors[0] + if (set(error) != {"code", "message"} or type(error.get("code")) is not int + or not 0 <= error["code"] <= 999_999 or not isinstance(error.get("message"), str)): + return None + return error["code"] + + +def _read_once(request: Request, *, opener_factory=build_opener) -> tuple[int, bytes, object]: + try: + with opener_factory(_NoRedirect).open(request, timeout=15) as response: + return response.status, response.read(_MAX_BODY_BYTES + 1), response.headers + except HTTPError as error: + try: + return error.code, error.read(_MAX_BODY_BYTES + 1), error.headers or {} + finally: + error.close() + + +def diagnose_receiver(*, token: str, opener_factory=build_opener) -> ReceiverDiagnosis: + if not isinstance(token, str) or not token or token != token.strip(): + return ReceiverDiagnosis("token_missing") + request = Request( + QRS_ENDPOINT, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + method="GET", + ) + try: + status, raw, headers = _read_once(request, opener_factory=opener_factory) + except Exception: + return ReceiverDiagnosis("transport") + body_shape, safe_code, cf_code = _json_body_shape(raw, headers) + if status == 200 and body_shape == "readiness": + return ReceiverDiagnosis("ready", status, body_shape) + if safe_code: + return ReceiverDiagnosis(safe_code, status, body_shape, safe_code) + if cf_code is not None: + return ReceiverDiagnosis("cloudflare_rejected", status, body_shape, cloudflare_code=cf_code) + if 300 <= status <= 399: + return ReceiverDiagnosis("redirect", status, body_shape) + if 100 <= status <= 599: + return ReceiverDiagnosis("http_rejected", status, body_shape) + return ReceiverDiagnosis("response_invalid", body_shape=body_shape) + + +def main() -> int: + result = diagnose_receiver(token=os.environ.get("BINANCE_ACCOUNT_FACTS_SYNC_TOKEN", "")) + fields = [f"category={result.category}"] + if result.http_status is not None: + fields.append(f"http_status={result.http_status}") + fields.append(f"body_shape={result.body_shape}") + if result.receiver_error is not None: + fields.append(f"receiver_code={result.receiver_error}") + if result.cloudflare_code is not None: + fields.append(f"cloudflare_code={result.cloudflare_code}") + print("binance_account_facts_receiver_diagnosis " + " ".join(fields)) + return 0 if result.category == "ready" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish_binance_account_facts.py b/scripts/publish_binance_account_facts.py index 86dde13a..4e26af43 100644 --- a/scripts/publish_binance_account_facts.py +++ b/scripts/publish_binance_account_facts.py @@ -47,20 +47,29 @@ def redirect_request(self, request, response, code, message, headers, new_url): def _http_failure_code(error: HTTPError) -> str: + status = error.code + if type(status) is not int or not 100 <= status <= 599: + return _HTTP_REJECTED try: raw = error.read(4097) - if not isinstance(raw, bytes) or len(raw) > 4096: - return _HTTP_REJECTED - body = json.loads(raw.decode("utf-8")) - if (not isinstance(body, dict) or set(body) != {"ok", "error"} - or body.get("ok") is not False or not isinstance(body.get("error"), str)): - return _HTTP_REJECTED - label = _HTTP_ERROR_LABELS.get((error.code, body["error"])) - if label is None: - return _HTTP_REJECTED - return f"account_facts_publish_{label}" + if not isinstance(raw, bytes): + shape = "invalid_body" + elif len(raw) > 4096: + shape = "body_oversized" + else: + shape = "body_invalid_json" + body = json.loads(raw.decode("utf-8")) + if isinstance(body, dict): + shape = "body_unrecognized_json" + if (set(body) == {"ok", "error"} and body.get("ok") is False + and isinstance(body.get("error"), str)): + shape = "body_unknown_error_code" + label = _HTTP_ERROR_LABELS.get((status, body["error"])) + if label is not None: + return f"account_facts_publish_{label}" + return f"account_facts_publish_http_{status}_{shape}" except (UnicodeError, ValueError, TypeError, RecursionError): - return _HTTP_REJECTED + return f"account_facts_publish_http_{status}_body_invalid_json" def publish_account_facts(*, facts_path: Path, env) -> str: diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 02f843b0..7acbc0af 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -1153,13 +1153,16 @@ def tracked_read(amount=None): assert reads == [4097] -@pytest.mark.parametrize(("status", "body"), [ - (400, b"{\"ok\":false,\"error\":\"unknown-private-code\",\"private\":\"SENSITIVE\"}"), - (418, b"SENSITIVE private body"), - (409, b"x" * 4097), +@pytest.mark.parametrize(("status", "body", "reason"), [ + (400, b"{\"ok\":false,\"error\":\"unknown-private-code\",\"private\":\"SENSITIVE\"}", + "account_facts_publish_http_400_body_unrecognized_json"), + (400, b"{\"ok\":false,\"error\":\"unknown-private-code\"}", + "account_facts_publish_http_400_body_unknown_error_code"), + (418, b"SENSITIVE private body", "account_facts_publish_http_418_body_invalid_json"), + (409, b"x" * 4097, "account_facts_publish_http_409_body_oversized"), ]) def test_publisher_rejects_unknown_or_oversized_http_error_body_closed( - tmp_path, monkeypatch, status, body + tmp_path, monkeypatch, status, body, reason ): from io import BytesIO from urllib.error import HTTPError @@ -1196,7 +1199,8 @@ def tracked_read(amount=None): "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", }, ) - assert str(raised.value) == "account_facts_publish_http_rejected" + assert str(raised.value) == reason + assert str(status) in str(raised.value) assert "SENSITIVE" not in str(raised.value) assert "private.example" not in str(raised.value) assert reads == [4097] @@ -1274,3 +1278,148 @@ def open(self, _request, timeout): assert str(raised.value) == "account_facts_publish_ack_invalid" assert "synthetic-secret" not in str(raised.value) assert calls == [20] + + +def test_receiver_diagnosis_is_one_authenticated_get_with_closed_success(): + from scripts import diagnose_binance_account_facts_receiver as diagnosis + from scripts import publish_binance_account_facts as publisher + + assert diagnosis.QRS_ENDPOINT == publisher.QRS_ENDPOINT + calls = [] + + class Response: + status = 200 + headers = {"Content-Type": "application/json"} + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, limit): + assert limit == 4097 + return json.dumps({ + "ok": True, "ready": True, "binding_valid": True, + "account_options_readable": True, "unique_match": True, + }).encode() + + class Opener: + def open(self, request, timeout): + calls.append(request) + assert timeout == 15 + return Response() + + result = diagnosis.diagnose_receiver(token="synthetic-secret", opener_factory=lambda _handler: Opener()) + assert result.category == "ready" + assert result.http_status == 200 + assert result.body_shape == "readiness" + assert len(calls) == 1 + assert calls[0].method == "GET" + assert calls[0].data is None + assert calls[0].get_header("Authorization") == "Bearer synthetic-secret" + assert "synthetic-secret" not in repr(result) + + +@pytest.mark.parametrize( + "status,body,headers,category,shape,receiver_code,cf_code", + [ + (403, {"success": False, "errors": [{"code": 1020, "message": "private edge detail"}], + "messages": [], "result": None}, {"Server": "cloudflare"}, + "cloudflare_rejected", "cloudflare_error", None, 1020), + (403, {"private": "sensitive response"}, {"Server": "cloudflare"}, + "http_rejected", "json_object_other", None, None), + (401, {"ok": False, "error": []}, {}, + "http_rejected", "json_object_other", None, None), + (401, {"ok": False, "error": {}}, {}, + "http_rejected", "json_object_other", None, None), + (401, {"ok": False, "error": "binance_account_facts_token_invalid"}, {}, + "token_invalid", "token_error", "token_invalid", None), + (503, {"ok": False, "ready": False, "binding_valid": False, + "account_options_readable": False, "unique_match": False, + "error": "binance_account_facts_account_options_unavailable"}, {}, + "account_options_unavailable", "receiver_error", "account_options_unavailable", None), + ], +) +def test_receiver_diagnosis_emits_only_closed_status_and_body_metadata( + status, body, headers, category, shape, receiver_code, cf_code +): + from io import BytesIO + from urllib.error import HTTPError + from scripts import diagnose_binance_account_facts_receiver as diagnosis + + calls = [] + + class Opener: + def open(self, request, timeout): + calls.append(request) + assert timeout == 15 + error = HTTPError( + "https://private.example/private-path", status, "sensitive exception", + headers, BytesIO(json.dumps(body).encode()), + ) + raise error + + result = diagnosis.diagnose_receiver(token="synthetic-token", opener_factory=lambda _handler: Opener()) + assert result.category == category + assert result.http_status == status + assert result.body_shape == shape + assert result.receiver_error == receiver_code + assert result.cloudflare_code == cf_code + assert len(calls) == 1 and calls[0].method == "GET" and calls[0].data is None + assert "private edge detail" not in repr(result) + assert "private.example" not in repr(result) + assert "sensitive exception" not in repr(result) + + +def test_receiver_diagnosis_missing_token_and_transport_failure_never_retry(): + from scripts import diagnose_binance_account_facts_receiver as diagnosis + + calls = [] + missing = diagnosis.diagnose_receiver(token="", opener_factory=lambda *_: calls.append("unexpected")) + assert missing.category == "token_missing" + assert calls == [] + + class Opener: + def open(self, request, timeout): + calls.append((request.method, timeout)) + raise OSError("private transport detail") + + failed = diagnosis.diagnose_receiver(token="synthetic-token", opener_factory=lambda _handler: Opener()) + assert failed.category == "transport" + assert len(calls) == 1 and calls[0] == ("GET", 15) + + +def test_receiver_diagnosis_rejects_ambiguous_json_and_unverified_cloudflare_code(): + from scripts import diagnose_binance_account_facts_receiver as diagnosis + + shape, code, cf_code = diagnosis._json_body_shape( + b'{"ok":true,"ok":false}', {"Server": "cloudflare"} + ) + assert (shape, code, cf_code) == ("invalid_json", None, None) + envelope = json.dumps({ + "success": False, + "errors": [{"code": 1020, "message": "private"}], + "messages": [], "result": None, + }).encode() + shape, code, cf_code = diagnosis._json_body_shape(envelope, {"Server": "other"}) + assert (shape, code, cf_code) == ("json_object_other", None, None) + + +def test_receiver_diagnosis_workflow_is_manual_main_only_and_get_only(): + workflow = (Path(__file__).parents[1] / ".github" / "workflows" + / "diagnose-account-facts-receiver.yml").read_text(encoding="utf-8") + assert '"on":\n workflow_dispatch:' in workflow + assert "refs/heads/main" in workflow + assert "git ls-remote https://github.com/QuantStrategyLab/BinancePlatform.git refs/heads/main" in workflow + assert 'ref: ${{ github.sha }}' in workflow + assert "git rev-parse HEAD" in workflow + assert "group: binance-account-facts-readonly" in workflow + assert "environment: binance-runtime" in workflow + assert "runs-on: self-hosted" in workflow + assert "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN }}" in workflow + assert "python3 scripts/diagnose_binance_account_facts_receiver.py" in workflow + assert "account_facts_publish" not in workflow + assert "read_binance_account_facts.py" not in workflow + assert "workflow_run:" not in workflow and "schedule:" not in workflow and "push:" not in workflow + assert "RUNTIME_TARGET_ENABLED" not in workflow and "BINANCE_ACCOUNT_FACTS_ENABLED" not in workflow From 0182b1436f206540330cea44bc711dcb4574befe Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:06:17 +0800 Subject: [PATCH 17/47] fix: identify isolated account facts HTTP client explicitly (#333) Co-authored-by: Codex --- ...diagnose_binance_account_facts_receiver.py | 7 +++++- scripts/publish_binance_account_facts.py | 2 ++ tests/test_binance_account_facts.py | 25 +++++++++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/scripts/diagnose_binance_account_facts_receiver.py b/scripts/diagnose_binance_account_facts_receiver.py index 7401fbc4..f9424d2b 100644 --- a/scripts/diagnose_binance_account_facts_receiver.py +++ b/scripts/diagnose_binance_account_facts_receiver.py @@ -9,6 +9,7 @@ from urllib.request import HTTPRedirectHandler, Request, build_opener QRS_ENDPOINT = "https://qsl-strategy-switch-console.pigbibi.workers.dev/api/internal/binance-account-facts" +_USER_AGENT = "QSL-AccountFacts-Readiness/1.0" _READINESS_FIELDS = {"ok", "ready", "binding_valid", "account_options_readable", "unique_match"} _READINESS_ERROR_FIELDS = _READINESS_FIELDS | {"error"} @@ -131,7 +132,11 @@ def diagnose_receiver(*, token: str, opener_factory=build_opener) -> ReceiverDia return ReceiverDiagnosis("token_missing") request = Request( QRS_ENDPOINT, - headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/json", + "User-Agent": _USER_AGENT, + }, method="GET", ) try: diff --git a/scripts/publish_binance_account_facts.py b/scripts/publish_binance_account_facts.py index 4e26af43..e07e08bb 100644 --- a/scripts/publish_binance_account_facts.py +++ b/scripts/publish_binance_account_facts.py @@ -17,6 +17,7 @@ QRS_ENDPOINT = "https://qsl-strategy-switch-console.pigbibi.workers.dev/api/internal/binance-account-facts" +_USER_AGENT = "QSL-AccountFacts-Readiness/1.0" _HTTP_ERROR_LABELS = { (400, "invalid_binance_account_facts"): "http_400_report_invalid", (400, "invalid_binance_account_facts_time"): "http_400_report_time_invalid", @@ -95,6 +96,7 @@ def publish_account_facts(*, facts_path: Path, env) -> str: "Authorization": f"Bearer {token}", "Content-Type": "application/json", "Accept": "application/json", + "User-Agent": _USER_AGENT, }, method="POST", ) diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 7acbc0af..2a81b5cf 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -1048,14 +1048,24 @@ def __exit__(self, *_args): def read(self, _limit): return json.dumps({"status": ack_status}).encode() + calls = [] + class Opener: def open(self, request, timeout): + calls.append((request, timeout)) assert request.method == "POST" assert request.full_url == publisher.QRS_ENDPOINT + assert request.get_header("User-agent") == "QSL-AccountFacts-Readiness/1.0" assert timeout == 20 return Response() - monkeypatch.setattr(publisher, "build_opener", lambda _handler: Opener()) + handlers = [] + + def build_opener(*received_handlers): + handlers.extend(received_handlers) + return Opener() + + monkeypatch.setattr(publisher, "build_opener", build_opener) assert publisher.publish_account_facts( facts_path=facts_path, env={ @@ -1063,6 +1073,8 @@ def open(self, request, timeout): "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", }, ) == ack_status + assert len(calls) == 1 + assert handlers == [publisher._NoRedirect] def test_publisher_rejects_legacy_recorded_ack(tmp_path, monkeypatch): @@ -1310,14 +1322,23 @@ def open(self, request, timeout): assert timeout == 15 return Response() - result = diagnosis.diagnose_receiver(token="synthetic-secret", opener_factory=lambda _handler: Opener()) + handlers = [] + + def opener_factory(*received_handlers): + handlers.extend(received_handlers) + return Opener() + + result = diagnosis.diagnose_receiver(token="synthetic-secret", opener_factory=opener_factory) assert result.category == "ready" assert result.http_status == 200 assert result.body_shape == "readiness" assert len(calls) == 1 + assert calls[0].full_url == diagnosis.QRS_ENDPOINT assert calls[0].method == "GET" assert calls[0].data is None assert calls[0].get_header("Authorization") == "Bearer synthetic-secret" + assert calls[0].get_header("User-agent") == "QSL-AccountFacts-Readiness/1.0" + assert handlers == [diagnosis._NoRedirect] assert "synthetic-secret" not in repr(result) From 2696cce8041670de8698acff9c23de78ffbf97be Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 3 Oct 2026 04:24:31 +0800 Subject: [PATCH 18/47] Add native USDT wallet valuation to read-only account facts (#334) * feat: read native USDT wallet valuation for account facts Co-Authored-By: Codex * test: isolate locked SDK wallet request verification Co-Authored-By: Codex --------- Co-authored-by: Codex --- application/account_facts.py | 145 ++++++++++++++++++++- scripts/read_binance_account_facts.py | 2 +- tests/test_binance_account_facts.py | 181 +++++++++++++++++++++++++- 3 files changed, 322 insertions(+), 6 deletions(-) diff --git a/application/account_facts.py b/application/account_facts.py index 5f138333..163e6e58 100644 --- a/application/account_facts.py +++ b/application/account_facts.py @@ -27,8 +27,21 @@ UNCOVERED_SCOPES = ["funding", "margin", "futures", "locked_earn"] PAGE_SIZE = 100 MAX_EARN_POSITIONS = 10_000 +MAX_WALLET_ROWS = 32 +WALLET_VALUATION_CURRENCY = "USDT" +WALLET_VALUATION_SOURCE = "GET /sapi/v1/asset/wallet/balance" +WALLET_VALUATION_SCOPE = "provider_returned_wallet_rows" +WALLET_VALUATION_FAILURES = frozenset({ + "wallet_read_failed", + "wallet_response_invalid", + "wallet_row_invalid", + "wallet_duplicate_name", + "wallet_inactive_nonzero", + "wallet_balance_invalid", +}) _HEX_SHA256 = re.compile(r"[0-9a-f]{64}\Z") _GIT_SHA = re.compile(r"[0-9a-f]{40}\Z") +_WALLET_DECIMAL = re.compile(r"(?:0|[1-9]\d{0,29})(?:\.\d{1,30})?\Z") _HANGUL_FILLERS = frozenset("\u115f\u1160\u3164\uffa0") @@ -66,6 +79,17 @@ def get_simple_earn_flexible_product_position( current=current, size=size ) + def get_wallet_valuation_usdt(self) -> Any: + """Read quote-valued wallet rows using the locked SDK's signed SAPI path.""" + return self.__client._request_margin_api( + "get", + "asset/wallet/balance", + signed=True, + version=1, + data={"quoteAsset": WALLET_VALUATION_CURRENCY, "needBalanceDetail": "false"}, + allow_redirects=False, + ) + def _fail(code: str) -> AccountFactsUnavailable: return AccountFactsUnavailable(f"binance_account_facts_{code}") @@ -124,6 +148,91 @@ def _decimal_text(value: Decimal) -> str: raise _fail("amount_invalid") from None +def _wallet_valuation_unavailable(reason_code: str) -> dict[str, Any]: + if reason_code not in WALLET_VALUATION_FAILURES: + reason_code = "wallet_response_invalid" + return { + "status": "unavailable", + "amount": None, + "currency": None, + "source": WALLET_VALUATION_SOURCE, + "scope": WALLET_VALUATION_SCOPE, + "observed_at": None, + "wallet_count": None, + "reason_code": reason_code, + } + + +def _wallet_valuation(response: Any, observed_at: datetime) -> dict[str, Any]: + if not isinstance(response, list) or not 1 <= len(response) <= MAX_WALLET_ROWS: + return _wallet_valuation_unavailable("wallet_response_invalid") + names: set[str] = set() + total = Decimal(0) + try: + with localcontext() as context: + context.prec = 100 + for row in response: + if not isinstance(row, Mapping) or set(row) != {"activate", "balance", "walletName"}: + return _wallet_valuation_unavailable("wallet_row_invalid") + name, active, balance_text = row["walletName"], row["activate"], row["balance"] + if not isinstance(name, str) or not name.strip() or len(name) > 128 or type(active) is not bool: + return _wallet_valuation_unavailable("wallet_row_invalid") + if name in names: + return _wallet_valuation_unavailable("wallet_duplicate_name") + names.add(name) + if not isinstance(balance_text, str) or not _WALLET_DECIMAL.fullmatch(balance_text): + return _wallet_valuation_unavailable("wallet_balance_invalid") + amount = _decimal(balance_text) + if amount and not active: + return _wallet_valuation_unavailable("wallet_inactive_nonzero") + total += amount + except AccountFactsUnavailable: + return _wallet_valuation_unavailable("wallet_balance_invalid") + except Exception: + return _wallet_valuation_unavailable("wallet_response_invalid") + try: + amount_text = _decimal_text(total) + observed_text = _utc_timestamp(observed_at) + except AccountFactsUnavailable: + return _wallet_valuation_unavailable("wallet_response_invalid") + return { + "status": "available", + "amount": amount_text, + "currency": WALLET_VALUATION_CURRENCY, + "source": WALLET_VALUATION_SOURCE, + "scope": WALLET_VALUATION_SCOPE, + "observed_at": observed_text, + "wallet_count": len(response), + } + + +def _validate_wallet_valuation(summary: Any) -> None: + common = {"status", "amount", "currency", "source", "scope", "observed_at", "wallet_count"} + if not isinstance(summary, Mapping): + raise _fail("payload_invalid") + if summary.get("source") != WALLET_VALUATION_SOURCE or summary.get("scope") != WALLET_VALUATION_SCOPE: + raise _fail("payload_invalid") + if summary.get("status") == "available": + if set(summary) != common or summary.get("currency") != WALLET_VALUATION_CURRENCY: + raise _fail("payload_invalid") + amount, count = summary.get("amount"), summary.get("wallet_count") + if not isinstance(amount, str) or type(count) is not int or not 1 <= count <= MAX_WALLET_ROWS: + raise _fail("payload_invalid") + if not _WALLET_DECIMAL.fullmatch(amount): + raise _fail("payload_invalid") + parsed = _decimal(amount) + if _decimal_text(parsed) != amount or not isinstance(summary.get("observed_at"), str): + raise _fail("payload_invalid") + _parsed_timestamp(summary["observed_at"]) + elif summary.get("status") == "unavailable": + if set(summary) != common | {"reason_code"} or any( + summary.get(key) is not None for key in ("amount", "currency", "observed_at", "wallet_count") + ) or summary.get("reason_code") not in WALLET_VALUATION_FAILURES: + raise _fail("payload_invalid") + else: + raise _fail("payload_invalid") + + def build_source_binding_id( *, account_scope_sha256: str, reader_public_revision: str, approved_application_revision: str, @@ -161,7 +270,7 @@ def collect_account_facts( observed_started_at: datetime, clock, ) -> dict[str, Any]: - """Collect complete Spot plus Flexible Earn quantities, without valuation.""" + """Collect Spot + Flexible Earn quantities and independent wallet valuation.""" if not isinstance(client, ReadOnlyBinanceClient): raise _fail("readonly_client_required") if not isinstance(target_id, str) or not target_id.strip(): @@ -272,11 +381,27 @@ def collect_account_facts( raise _fail("flexible_earn_page_incomplete") earn_finished_at = clock() earn_finished = _utc_timestamp(earn_finished_at) + try: + wallet_response = client.get_wallet_valuation_usdt() + except Exception: + wallet_response = None + wallet_valuation = _wallet_valuation_unavailable("wallet_read_failed") + wallet_observed_at = None + else: + wallet_observed_at = clock() + wallet_valuation = _wallet_valuation(wallet_response, wallet_observed_at) finished_at = clock() finished = _utc_timestamp(finished_at) if ( earn_finished_at.astimezone(timezone.utc) < spot_finished_at.astimezone(timezone.utc) or finished_at.astimezone(timezone.utc) < earn_finished_at.astimezone(timezone.utc) + or ( + wallet_observed_at is not None + and ( + wallet_observed_at.astimezone(timezone.utc) < earn_finished_at.astimezone(timezone.utc) + or finished_at.astimezone(timezone.utc) < wallet_observed_at.astimezone(timezone.utc) + ) + ) ): raise _fail("observation_time_invalid") @@ -325,6 +450,7 @@ def collect_account_facts( "scope": SCOPE, "completeness": "complete_for_scope", "assets": asset_rows, + "wallet_valuation": wallet_valuation, "uncovered_scopes": list(UNCOVERED_SCOPES), "no_order": True, "execution_authority_granted": False, @@ -340,7 +466,11 @@ def validate_account_facts_payload(payload: Any) -> dict[str, Any]: "earn_observed_at", "snapshot_atomic", "scope", "completeness", "assets", "uncovered_scopes", "no_order", "execution_authority_granted", } - if not isinstance(payload, Mapping) or set(payload) != required: + if ( + not isinstance(payload, Mapping) + or not required.issubset(payload) + or set(payload) - required - {"wallet_valuation"} + ): raise _fail("payload_invalid") if ( payload.get("schema_version") != SCHEMA_VERSION @@ -409,4 +539,15 @@ def validate_account_facts_payload(payload: Any) -> dict[str, Any]: context.prec = 100 if quantity != spot_free + spot_locked + flexible_earn: raise _fail("payload_invalid") + if "wallet_valuation" in payload: + _validate_wallet_valuation(payload["wallet_valuation"]) + wallet_valuation = payload["wallet_valuation"] + if wallet_valuation["status"] == "available": + wallet_observed_at = _parsed_timestamp(wallet_valuation["observed_at"]) + if not ( + observation_times["earn_observed_at"] + <= wallet_observed_at + <= observation_times["observed_finished_at"] + ): + raise _fail("payload_invalid") return dict(payload) diff --git a/scripts/read_binance_account_facts.py b/scripts/read_binance_account_facts.py index 96f265ea..d3b03d88 100644 --- a/scripts/read_binance_account_facts.py +++ b/scripts/read_binance_account_facts.py @@ -886,7 +886,7 @@ def read_account_facts(*, report_path: Path, output_path: Path, env: Mapping[str verify_current_source_from_env(env) # Client construction is network-silent: python-binance ping is disabled. - # The capability wrapper exposes only the two signed wallet GET endpoints. + # The capability wrapper exposes only the required signed read-only GETs. try: from binance.client import Client raw_client = Client( diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 2a81b5cf..c47a0741 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -2,6 +2,9 @@ import hashlib import json +import subprocess +import sys +import textwrap from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.error import URLError @@ -45,6 +48,13 @@ def get_simple_earn_flexible_product_position(self, *, current, size): self.calls.append(("earn", current, size)) return self.earn_pages[current - 1] + def _request_margin_api(self, method, path, **kwargs): + self.calls.append(("wallet", method, path, kwargs)) + return [ + {"activate": True, "balance": "12.25", "walletName": "SPOT"}, + {"activate": True, "balance": "2.75", "walletName": "FUNDING"}, + ] + def _uid_hash() -> str: from quant_platform_kit.common.broker_reconciliation import calculate_broker_observation_sha256 @@ -54,7 +64,7 @@ def _uid_hash() -> str: def _collect(client=None, times=None): raw = client or FakeBinance() - clock_values = iter(times or [START + timedelta(seconds=i) for i in (1, 2, 3)]) + clock_values = iter(times or [START + timedelta(seconds=i) for i in (1, 2, 3, 4)]) return collect_account_facts( ReadOnlyBinanceClient(raw), expected_account_scope_sha256=_uid_hash(), @@ -83,9 +93,173 @@ def test_collects_spot_and_all_flexible_earn_as_native_quantities(): validate_account_facts_payload(payload) +def test_wallet_valuation_is_separate_usdt_provider_total(): + fake = FakeBinance() + payload = _collect(fake) + assert payload["assets"][0]["quantity"] == "0.85" + assert payload["wallet_valuation"] == { + "status": "available", + "amount": "15", + "currency": "USDT", + "source": "GET /sapi/v1/asset/wallet/balance", + "scope": "provider_returned_wallet_rows", + "observed_at": "2026-10-02T10:00:03Z", + "wallet_count": 2, + } + assert [call[0] for call in fake.calls] == ["spot", "earn", "wallet"] + validate_account_facts_payload(payload) + + +@pytest.mark.parametrize( + ("rows", "reason"), + [ + (None, "wallet_response_invalid"), + ([], "wallet_response_invalid"), + ([{"activate": True, "balance": "1", "walletName": "SPOT", "unexpected": 1}], "wallet_row_invalid"), + ([{"activate": True, "balance": "1", "walletName": "SPOT"}] * 2, "wallet_duplicate_name"), + ([{"activate": False, "balance": "1", "walletName": "SPOT"}], "wallet_inactive_nonzero"), + ([{"activate": True, "balance": "-1", "walletName": "SPOT"}], "wallet_balance_invalid"), + ([{"activate": True, "balance": "NaN", "walletName": "SPOT"}], "wallet_balance_invalid"), + ([{"activate": True, "balance": "1e2", "walletName": "SPOT"}], "wallet_balance_invalid"), + ([{"activate": 1, "balance": "1", "walletName": "SPOT"}], "wallet_row_invalid"), + ], +) +def test_invalid_wallet_valuation_stays_unavailable_without_losing_quantities(rows, reason): + class WalletResponseBinance(FakeBinance): + def _request_margin_api(self, method, path, **kwargs): + self.calls.append(("wallet", method, path, kwargs)) + return rows + + payload = _collect(WalletResponseBinance()) + assert payload["assets"] + assert payload["wallet_valuation"] == { + "status": "unavailable", + "amount": None, + "currency": None, + "source": "GET /sapi/v1/asset/wallet/balance", + "scope": "provider_returned_wallet_rows", + "observed_at": None, + "wallet_count": None, + "reason_code": reason, + } + validate_account_facts_payload(payload) + + +def test_wallet_read_failure_does_not_discard_native_quantities(): + class WalletUnavailable(FakeBinance): + def _request_margin_api(self, method, path, **kwargs): + raise RuntimeError("synthetic-private-error") + + payload = _collect(WalletUnavailable()) + assert payload["assets"] + assert payload["wallet_valuation"]["reason_code"] == "wallet_read_failed" + assert "synthetic-private-error" not in json.dumps(payload) + validate_account_facts_payload(payload) + + +def test_inactive_zero_wallet_is_valid_and_zero_total_is_not_missing(): + class ZeroWallet(FakeBinance): + def _request_margin_api(self, method, path, **kwargs): + return [{"activate": False, "balance": "0.00000000", "walletName": "SPOT"}] + + payload = _collect(ZeroWallet()) + assert payload["wallet_valuation"]["status"] == "available" + assert payload["wallet_valuation"]["amount"] == "0" + assert payload["wallet_valuation"]["wallet_count"] == 1 + validate_account_facts_payload(payload) + + +def test_wallet_decimal_sum_preserves_precision_and_rejects_excessive_rows(): + class PreciseWallet(FakeBinance): + def _request_margin_api(self, method, path, **kwargs): + return [ + {"activate": True, "balance": "0.123456789012345678901234567890", "walletName": "A"}, + {"activate": True, "balance": "0.000000000000000000000000000001", "walletName": "B"}, + ] + + payload = _collect(PreciseWallet()) + assert payload["wallet_valuation"]["amount"] == "0.123456789012345678901234567891" + + class TooManyWallets(FakeBinance): + def _request_margin_api(self, method, path, **kwargs): + return [ + {"activate": True, "balance": "0", "walletName": f"WALLET-{index}"} + for index in range(33) + ] + + overflow = _collect(TooManyWallets()) + assert overflow["wallet_valuation"]["status"] == "unavailable" + assert overflow["wallet_valuation"]["reason_code"] == "wallet_response_invalid" + + +def test_wallet_valuation_payload_rejects_unrecognized_failure_reason(): + payload = _collect() + payload["wallet_valuation"] = { + "status": "unavailable", + "amount": None, + "currency": None, + "source": "GET /sapi/v1/asset/wallet/balance", + "scope": "provider_returned_wallet_rows", + "observed_at": None, + "wallet_count": None, + "reason_code": "synthetic-unrecognized", + } + with pytest.raises(AccountFactsUnavailable, match="payload_invalid"): + validate_account_facts_payload(payload) + + +def test_wallet_request_uses_locked_sdk_signed_get_once_without_redirect(): + probe = textwrap.dedent( + """ + import requests + from binance.client import Client + from application.account_facts import ReadOnlyBinanceClient + + raw_client = Client( + "synthetic-key", "synthetic-secret", requests_params={"timeout": 15}, ping=False + ) + captured = [] + + def fake_get(uri, **kwargs): + captured.append((uri, kwargs)) + response = requests.Response() + response.status_code = 200 + response.url = uri + response.encoding = "utf-8" + response._content = b'[{"activate":true,"balance":"1.25","walletName":"SPOT"}]' + return response + + raw_client.session.get = fake_get + response = ReadOnlyBinanceClient(raw_client).get_wallet_valuation_usdt() + assert response == [{"activate": True, "balance": "1.25", "walletName": "SPOT"}] + assert len(captured) == 1 + uri, kwargs = captured[0] + assert uri == "https://api.binance.com/sapi/v1/asset/wallet/balance" + assert kwargs["timeout"] == 15 + assert kwargs["allow_redirects"] is False + params = kwargs["params"] + assert "quoteAsset=USDT" in params + assert "needBalanceDetail=false" in params + assert "timestamp=" in params and "signature=" in params + print("wallet-wire-ok") + """ + ) + result = subprocess.run( + [sys.executable, "-c", probe], + cwd=Path(__file__).resolve().parents[1], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, "isolated locked-SDK wallet wire probe failed" + assert result.stdout.strip() == "wallet-wire-ok" + + def test_synthetic_contract_fixture_matches_exact_producer_json(): fixture_path = Path(__file__).parent / "fixtures" / "binance_account_facts.v1.synthetic.json" - payload = _collect(times=[START + timedelta(seconds=i) for i in (1, 2, 3)]) + payload = _collect(times=[START + timedelta(seconds=i) for i in (1, 2, 3, 4)]) + payload.pop("wallet_valuation") + payload["observed_finished_at"] = "2026-10-02T10:00:03Z" encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False) + "\n" assert encoded == fixture_path.read_text(encoding="utf-8") @@ -607,7 +781,8 @@ def test_payload_validator_rejects_reordered_observations(): def test_zero_earn_positions_is_a_complete_empty_page(): client = FakeBinance(earn_pages=[{"rows": [], "total": 0}]) payload = _collect(client) - assert client.calls == [("spot",), ("earn", 1, 100)] + assert [call[0] for call in client.calls] == ["spot", "earn", "wallet"] + assert payload["wallet_valuation"]["status"] == "available" assert all(row["flexible_earn"] == "0" for row in payload["assets"]) From 16ecd718aa4e213f3f34dfd06ab09021497afa4d Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:16:40 +0800 Subject: [PATCH 19/47] feat: include Binance native API product type in account facts Co-Authored-By: Codex --- application/account_facts.py | 27 ++++++- .../binance_account_facts.v1.synthetic.json | 2 +- tests/test_binance_account_facts.py | 79 +++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/application/account_facts.py b/application/account_facts.py index 163e6e58..47d28d35 100644 --- a/application/account_facts.py +++ b/application/account_facts.py @@ -39,6 +39,7 @@ "wallet_inactive_nonzero", "wallet_balance_invalid", }) +PROVIDER_PRODUCT_TYPE_SOURCE = "GET /api/v3/account.accountType" _HEX_SHA256 = re.compile(r"[0-9a-f]{64}\Z") _GIT_SHA = re.compile(r"[0-9a-f]{40}\Z") _WALLET_DECIMAL = re.compile(r"(?:0|[1-9]\d{0,29})(?:\.\d{1,30})?\Z") @@ -298,6 +299,10 @@ def collect_account_facts( ) if account_scope_sha256 != expected_account_scope_sha256: raise _fail("account_identity_mismatch") + raw_account_type = account.get("accountType") + provider_product_value = ( + "SPOT" if type(raw_account_type) is str and raw_account_type == "SPOT" else "unknown" + ) balances = account.get("balances") if not isinstance(balances, list): @@ -445,6 +450,11 @@ def collect_account_facts( "observed_started_at": started, "observed_finished_at": finished, "spot_observed_at": spot_finished, + "provider_product_type": { + "value": provider_product_value, + "source": PROVIDER_PRODUCT_TYPE_SOURCE, + "observed_at": spot_finished, + }, "earn_observed_at": earn_finished, "snapshot_atomic": False, "scope": SCOPE, @@ -469,7 +479,7 @@ def validate_account_facts_payload(payload: Any) -> dict[str, Any]: if ( not isinstance(payload, Mapping) or not required.issubset(payload) - or set(payload) - required - {"wallet_valuation"} + or set(payload) - required - {"wallet_valuation", "provider_product_type"} ): raise _fail("payload_invalid") if ( @@ -513,6 +523,21 @@ def validate_account_facts_payload(payload: Any) -> dict[str, Any]: <= observation_times["observed_finished_at"] ): raise _fail("payload_invalid") + if "provider_product_type" in payload: + provider_product_type = payload["provider_product_type"] + if ( + not isinstance(provider_product_type, Mapping) + or set(provider_product_type) != {"value", "source", "observed_at"} + or not isinstance(provider_product_type.get("value"), str) + or provider_product_type.get("value") not in ("SPOT", "unknown") + or provider_product_type.get("source") != PROVIDER_PRODUCT_TYPE_SOURCE + or not isinstance(provider_product_type.get("observed_at"), str) + or provider_product_type["observed_at"] != payload.get("spot_observed_at") + ): + raise _fail("payload_invalid") + product_type_time = _parsed_timestamp(provider_product_type["observed_at"]) + if _utc_timestamp(product_type_time) != provider_product_type["observed_at"]: + raise _fail("payload_invalid") assets = payload.get("assets") if not isinstance(assets, list): raise _fail("payload_invalid") diff --git a/tests/fixtures/binance_account_facts.v1.synthetic.json b/tests/fixtures/binance_account_facts.v1.synthetic.json index fa5c4d9e..039ff6a0 100644 --- a/tests/fixtures/binance_account_facts.v1.synthetic.json +++ b/tests/fixtures/binance_account_facts.v1.synthetic.json @@ -1 +1 @@ -{"account_scope_sha256":"ffc85145b8648546510725b8f636bfcc748a566522ca710d96ed6b2935824067","approved_application_revision":"8cb56617115fa45028e34d788e71884b6a303d77","assets":[{"asset":"BTC","flexible_earn":"0.25","quantity":"0.85","spot_free":"0.5","spot_locked":"0.1"},{"asset":"USDT","flexible_earn":"0","quantity":"10","spot_free":"10","spot_locked":"0"}],"completeness":"complete_for_scope","earn_observed_at":"2026-10-02T10:00:02Z","execution_authority_granted":false,"no_order":true,"observed_finished_at":"2026-10-02T10:00:03Z","observed_started_at":"2026-10-02T10:00:00Z","platform":"binance","schema_version":"binance_account_facts.v1","scope":"spot+flexible_earn","snapshot_atomic":false,"source_binding":{"id":"f2a4d3fd78eb6b9749fd4248d78d8a78a33c6651e5d9194b9bd12e21d68b20ac","kind":"binance_readonly_scope_revision"},"source_revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","spot_observed_at":"2026-10-02T10:00:01Z","target_id":"synthetic-target","uncovered_scopes":["funding","margin","futures","locked_earn"]} +{"account_scope_sha256":"ffc85145b8648546510725b8f636bfcc748a566522ca710d96ed6b2935824067","approved_application_revision":"8cb56617115fa45028e34d788e71884b6a303d77","assets":[{"asset":"BTC","flexible_earn":"0.25","quantity":"0.85","spot_free":"0.5","spot_locked":"0.1"},{"asset":"USDT","flexible_earn":"0","quantity":"10","spot_free":"10","spot_locked":"0"}],"completeness":"complete_for_scope","earn_observed_at":"2026-10-02T10:00:02Z","execution_authority_granted":false,"no_order":true,"observed_finished_at":"2026-10-02T10:00:03Z","observed_started_at":"2026-10-02T10:00:00Z","platform":"binance","provider_product_type":{"observed_at":"2026-10-02T10:00:01Z","source":"GET /api/v3/account.accountType","value":"SPOT"},"schema_version":"binance_account_facts.v1","scope":"spot+flexible_earn","snapshot_atomic":false,"source_binding":{"id":"f2a4d3fd78eb6b9749fd4248d78d8a78a33c6651e5d9194b9bd12e21d68b20ac","kind":"binance_readonly_scope_revision"},"source_revision":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","spot_observed_at":"2026-10-02T10:00:01Z","target_id":"synthetic-target","uncovered_scopes":["funding","margin","futures","locked_earn"]} diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index c47a0741..28f3ee7c 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -38,6 +38,7 @@ def get_account(self): self.calls.append(("spot",)) return { "uid": "123456", + "accountType": "SPOT", "balances": [ {"asset": "BTC", "free": "0.5", "locked": "0.1"}, {"asset": "USDT", "free": "10", "locked": "0"}, @@ -78,6 +79,11 @@ def _collect(client=None, times=None): def test_collects_spot_and_all_flexible_earn_as_native_quantities(): payload = _collect() + assert payload["provider_product_type"] == { + "value": "SPOT", + "source": "GET /api/v3/account.accountType", + "observed_at": payload["spot_observed_at"], + } assert payload["scope"] == "spot+flexible_earn" assert payload["snapshot_atomic"] is False assert payload["uncovered_scopes"] == ["funding", "margin", "futures", "locked_earn"] @@ -93,6 +99,79 @@ def test_collects_spot_and_all_flexible_earn_as_native_quantities(): validate_account_facts_payload(payload) +@pytest.mark.parametrize( + ("account_type", "expected"), + [ + ("SPOT", "SPOT"), + ("MARGIN", "unknown"), + ("", "unknown"), + ("__missing__", "unknown"), + (None, "unknown"), + ({"injected": "private"}, "unknown"), + ("private-injected-value", "unknown"), + ], +) +def test_provider_product_type_normalizes_once_from_spot_response(account_type, expected): + class ProductTypeBinance(FakeBinance): + def get_account(self): + result = super().get_account() + if account_type == "__missing__": + result.pop("accountType") + else: + result["accountType"] = account_type + return result + + fake = ProductTypeBinance() + payload = _collect(fake) + assert payload["provider_product_type"] == { + "value": expected, + "source": "GET /api/v3/account.accountType", + "observed_at": payload["spot_observed_at"], + } + assert [call[0] for call in fake.calls].count("spot") == 1 + assert "private-injected-value" not in json.dumps(payload) + validate_account_facts_payload(payload) + + +def test_provider_product_type_timestamp_is_exact_spot_observation(): + payload = _collect() + assert payload["provider_product_type"]["observed_at"] == payload["spot_observed_at"] + for invalid in ("2026-10-02T10:00:02", "not-a-time", "2026-10-02T10:00:03+08:00"): + malformed = dict(payload) + malformed["provider_product_type"] = { + **payload["provider_product_type"], + "observed_at": invalid, + } + with pytest.raises(AccountFactsUnavailable, match="payload_invalid"): + validate_account_facts_payload(malformed) + + +def test_provider_product_type_optional_validator_preserves_legacy_and_binding(): + payload = _collect() + binding = payload["source_binding"]["id"] + legacy = dict(payload) + legacy.pop("provider_product_type") + assert validate_account_facts_payload(legacy) == legacy + assert payload["source_binding"]["id"] == binding + assert payload["assets"] == legacy["assets"] + + +@pytest.mark.parametrize( + "product_type", + [ + {"value": "SPOT", "source": "wrong", "observed_at": "2026-10-02T10:00:01Z"}, + {"value": "MARGIN", "source": "GET /api/v3/account.accountType", "observed_at": "2026-10-02T10:00:01Z"}, + {"value": "SPOT", "source": "GET /api/v3/account.accountType", "observed_at": "2026-10-02T10:00:01Z", "extra": "x"}, + {"value": "SPOT", "source": "GET /api/v3/account.accountType", "observed_at": "2026-10-02T10:00:01+00:00"}, + ], +) +def test_provider_product_type_rejects_malformed_optional_field(product_type): + payload = _collect() + payload["provider_product_type"] = product_type + with pytest.raises(AccountFactsUnavailable, match="payload_invalid"): + validate_account_facts_payload(payload) + + def test_wallet_valuation_is_separate_usdt_provider_total(): fake = FakeBinance() payload = _collect(fake) From 597577492c01e6a236d90fd635d1d834adfa128b Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 4 Oct 2026 03:55:46 +0800 Subject: [PATCH 20/47] fix: report only validated Binance product type category Co-Authored-By: Codex --- scripts/read_binance_account_facts.py | 19 +++++++++-- tests/test_binance_account_facts.py | 49 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/scripts/read_binance_account_facts.py b/scripts/read_binance_account_facts.py index d3b03d88..9c0b0d64 100644 --- a/scripts/read_binance_account_facts.py +++ b/scripts/read_binance_account_facts.py @@ -862,7 +862,13 @@ def _write_private_json(path: Path, payload: Mapping[str, Any]) -> None: raise _stop("output_write_failed") from None -def read_account_facts(*, report_path: Path, output_path: Path, env: Mapping[str, str]) -> None: +def _provider_product_type_for_log(payload: Mapping[str, Any]) -> str: + product_type = payload.get("provider_product_type") + value = product_type.get("value") if isinstance(product_type, Mapping) else None + return value if type(value) is str and value in ("SPOT", "unknown") else "unknown" + + +def read_account_facts(*, report_path: Path, output_path: Path, env: Mapping[str, str]) -> str: if env.get("BINANCE_ACCOUNT_FACTS_ENABLED") != "true": raise _stop("disabled") if env.get("RUNTIME_TARGET_ENABLED") != "true" or env.get("BINANCE_DRY_RUN") != "false": @@ -907,7 +913,9 @@ def read_account_facts(*, report_path: Path, output_path: Path, env: Mapping[str clock=lambda: datetime.now(timezone.utc), ) verify_current_source_from_env(env) - _write_private_json(output_path, validate_account_facts_payload(payload)) + validated_payload = validate_account_facts_payload(payload) + _write_private_json(output_path, validated_payload) + return _provider_product_type_for_log(validated_payload) def preflight(*, env: Mapping[str, str], output: Path) -> None: @@ -958,8 +966,13 @@ def main(argv: list[str] | None = None) -> int: return 0 if args.report is None or args.output is None: raise _stop("arguments_invalid") - read_account_facts(report_path=args.report, output_path=args.output, env=os.environ) + provider_product_type = read_account_facts( + report_path=args.report, output_path=args.output, env=os.environ + ) + if type(provider_product_type) is not str or provider_product_type not in ("SPOT", "unknown"): + provider_product_type = "unknown" print("account_facts_read=complete_for_scope") + print(f"account_facts_provider_product_type={provider_product_type}") return 0 except (ReaderError, AccountFactsUnavailable) as exc: print(str(exc), file=sys.stderr) diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 28f3ee7c..8d8e1b81 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -914,6 +914,55 @@ def test_active_runtime_blocks_read_before_account_client_creation(monkeypatch): reader._ensure_no_active_runtime_run(token="synthetic", api_url="https://api.example") +@pytest.mark.parametrize( + ("product_type", "expected"), + [ + ("SPOT", "SPOT"), + ("unknown", "unknown"), + (None, "unknown"), + ("MARGIN", "unknown"), + ({"value": "SPOT", "account_uid": "synthetic-private-id"}, "unknown"), + ("SPOT synthetic-secret 123456 0.5 " + "a" * 64, "unknown"), + ], +) +def test_account_facts_cli_emits_only_closed_product_type( + monkeypatch, capsys, tmp_path, product_type, expected +): + from scripts import read_binance_account_facts as reader + + monkeypatch.setattr(reader, "read_account_facts", lambda **_kwargs: product_type) + assert reader.main(["--report", str(tmp_path / "report.json"), "--output", str(tmp_path / "facts.json")]) == 0 + output = capsys.readouterr().out + assert "account_facts_read=complete_for_scope" in output + assert f"account_facts_provider_product_type={expected}" in output + for private_value in ("synthetic-private-id", "synthetic-secret", "123456", "0.5", "a" * 64): + assert private_value not in output + + +def test_account_facts_cli_failure_never_prints_product_type(monkeypatch, capsys, tmp_path): + from scripts import read_binance_account_facts as reader + + def fail(**_kwargs): + raise reader.ReaderError("account_facts_spot_read_failed") + + monkeypatch.setattr(reader, "read_account_facts", fail) + assert reader.main(["--report", str(tmp_path / "report.json"), "--output", str(tmp_path / "facts.json")]) == 1 + output = capsys.readouterr() + assert "account_facts_provider_product_type=" not in output.out + assert "account_facts_spot_read_failed" in output.err + + +def test_reader_log_category_uses_validated_optional_field_only(): + from scripts import read_binance_account_facts as reader + + payload = validate_account_facts_payload(_collect()) + assert reader._provider_product_type_for_log(payload) == "SPOT" + legacy = dict(payload) + legacy.pop("provider_product_type") + assert reader._provider_product_type_for_log(validate_account_facts_payload(legacy)) == "unknown" + assert reader._provider_product_type_for_log({"provider_product_type": "MARGIN"}) == "unknown" + + def test_release_log_plain_text_requires_one_exact_approved_line(monkeypatch): from scripts import read_binance_account_facts as reader From ff7bcb28a74cec9139c8f13b39af8ff4e55d0438 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:19:19 -0700 Subject: [PATCH 21/47] feat: add inactive interval source receipt candidate --- .../interval_source_receipt_candidate.py | 351 +++++++++ docs/interval_source_receipt_candidate.md | 124 +++ .../test_interval_source_receipt_candidate.py | 710 ++++++++++++++++++ 3 files changed, 1185 insertions(+) create mode 100644 application/interval_source_receipt_candidate.py create mode 100644 docs/interval_source_receipt_candidate.md create mode 100644 tests/test_interval_source_receipt_candidate.py diff --git a/application/interval_source_receipt_candidate.py b/application/interval_source_receipt_candidate.py new file mode 100644 index 00000000..0db69058 --- /dev/null +++ b/application/interval_source_receipt_candidate.py @@ -0,0 +1,351 @@ +"""Opt-in LOCAL CANDIDATE: retain an interval in the checkpoint transaction. + +No client, backend defaults, runtime registration, receipt query, delivery, or +deletion is provided. The caller must supply references and a transaction runner +from the SAME already-bound state backend. Tests use only an in-memory fake. +This does not qualify backend durability or a native PerformanceStore ACK. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +import re +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation + + +_FIELDS = { + "account_scope_sha256", "start_at", "end_at", "end_equity_usdt", + "net_external_cash_flow", "currency", "valuation_basis", +} +_PATCH_FIELDS = { + "earn_accrual_checkpoint", "external_cash_flow_cursor", + "earn_accounted_net_changes", "last_balance_snapshot", + "daily_external_principal_usdt", +} +_SCHEMA = "binance_interval_source_receipt_candidate.v1" +_BASIS = "checkpoint_quantities_sampled_prices" + + +class IntervalReceiptBlocked(ValueError): + """Fixed local reason; no write was requested after failed preconditions.""" + + +class IntervalReceiptConflict(IntervalReceiptBlocked): + """An existing create-once receipt differs; this API never replaces it.""" + + +class IntervalReceiptUncertain(RuntimeError): + """Commit/readback was not established; never interpret as an ACK/rollback.""" + + +def _json(value): + try: + return json.dumps(value, ensure_ascii=True, sort_keys=True, + separators=(",", ":"), allow_nan=False) + except (TypeError, ValueError): + raise IntervalReceiptBlocked("interval_receipt_material_invalid") from None + + +def _digest(value): + return hashlib.sha256(_json(value).encode("utf-8")).hexdigest() + + +def _time(value): + # Actual producer datetime strings have microsecond precision. Reject a + # finer payload timestamp rather than collapse distinct logical intervals. + if (not isinstance(value, str) or not 0 < len(value) <= 80 + or re.search(r"[.,][0-9]{7}", value)): + raise IntervalReceiptBlocked("interval_receipt_time_invalid") + try: + result = datetime.fromisoformat(value.replace("Z", "+00:00")) + if result.tzinfo is None: + raise ValueError + return result.astimezone(timezone.utc) + except (ValueError, OverflowError): + raise IntervalReceiptBlocked("interval_receipt_time_invalid") from None + + +def _version(value): + """Exact UTC seconds+nanos from snapshot.update_time, never isoformat CAS.""" + if not isinstance(value, datetime) or value.utcoffset() != timedelta(0): + raise IntervalReceiptBlocked("interval_receipt_version_invalid") + nanos = getattr(value, "nanosecond", 0) or value.microsecond * 1000 + if type(nanos) is not int or not 0 <= nanos < 1_000_000_000 or nanos // 1000 != value.microsecond: + raise IntervalReceiptBlocked("interval_receipt_version_invalid") + return value.strftime("%Y-%m-%dT%H:%M:%S") + f".{nanos:09d}Z" + + +def _amount(value, *, positive=False): + if not isinstance(value, str) or not 0 < len(value) <= 80: + raise IntervalReceiptBlocked("interval_receipt_amount_invalid") + try: + amount = Decimal(value) + if (not amount.is_finite() or amount.copy_abs() > Decimal("1e30") + or amount.as_tuple().exponent < -30 or (positive and amount <= 0)): + raise ValueError + return amount + except (InvalidOperation, ValueError): + raise IntervalReceiptBlocked("interval_receipt_amount_invalid") from None + + +@dataclass(frozen=True) +class IntervalReceiptPlan: + """Frozen serialized material, detached from the caller's mutable state.""" + + ledger_path: str + receipt_path: str + source_ledger_version: str + ledger_before_sha256: str + ledger_after_sha256: str + payload_sha256: str + patch_json: str + receipt_json: str + + +def _validate_path(value): + if (not isinstance(value, str) or len(value) > 512 + or len(value.split("/")) % 2 + or any(not part or part in {".", ".."} for part in value.split("/"))): + raise IntervalReceiptBlocked("interval_receipt_reference_invalid") + + +def _payload_material(interval): + if not isinstance(interval, Mapping) or set(interval) != _FIELDS: + raise IntervalReceiptBlocked("interval_receipt_payload_invalid") + payload = copy.deepcopy(dict(interval)) + scope = payload["account_scope_sha256"] + if (not isinstance(scope, str) or not re.fullmatch(r"[0-9a-f]{64}", scope) + or payload["currency"] != "USDT" or payload["valuation_basis"] != _BASIS): + raise IntervalReceiptBlocked("interval_receipt_payload_invalid") + start, end = _time(payload["start_at"]), _time(payload["end_at"]) + if not start < end or end.date() - start.date() > timedelta(days=1): + raise IntervalReceiptBlocked("interval_receipt_time_invalid") + _amount(payload["end_equity_usdt"], positive=True) + _amount(payload["net_external_cash_flow"]) + identity = {"account_scope_sha256": scope, "start_at": start.isoformat(), "end_at": end.isoformat()} + return payload, start, end, _digest(identity) + + +def _receipt_material(*, ledger_path, source_version, before_digest, after_digest, patch, payload): + _, _, _, logical_id = _payload_material(payload) + return { + "schema_version": _SCHEMA, + "interval_id_sha256": logical_id, + "payload_sha256": _digest(payload), + "source": { + "ledger_path": ledger_path, + "ledger_before_version": source_version, + "ledger_before_sha256": before_digest, + "ledger_after_sha256": after_digest, + "patch_sha256": _digest(patch), + }, + "interval": payload, + } + + +def _validate_plan(plan): + """A frozen dataclass is not a trust boundary; check all serialized bindings.""" + try: + _validate_path(plan.ledger_path) + patch, receipt = json.loads(plan.patch_json), json.loads(plan.receipt_json) + if (not isinstance(patch, dict) or set(patch) != _PATCH_FIELDS + or not isinstance(receipt, dict) or _json(patch) != plan.patch_json + or not isinstance(plan.source_ledger_version, str) + or not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{9}Z", plan.source_ledger_version) + or any(not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value) + for value in (plan.ledger_before_sha256, plan.ledger_after_sha256, plan.payload_sha256))): + raise ValueError + _time(plan.source_ledger_version[:19] + "Z") + payload, _, end, logical_id = _payload_material(receipt["interval"]) + checkpoint = patch["earn_accrual_checkpoint"] + assets = checkpoint["assets"] + if (checkpoint["account_scope_sha256"] != payload["account_scope_sha256"] + or checkpoint["execution_authority_granted"] is not False + or _time(checkpoint["observed_at"]) != end + or _time(patch["external_cash_flow_cursor"]["observed_at"]) != end + or not isinstance(assets, Mapping) or not assets + or patch["earn_accounted_net_changes"] != {asset: "0" for asset in assets} + or plan.receipt_path != plan.ledger_path + "__interval_receipt_" + logical_id + or _digest(payload) != plan.payload_sha256): + raise ValueError + expected = _receipt_material( + ledger_path=plan.ledger_path, source_version=plan.source_ledger_version, + before_digest=plan.ledger_before_sha256, after_digest=plan.ledger_after_sha256, + patch=patch, payload=payload, + ) + if _json(expected) != plan.receipt_json: + raise ValueError + return patch, payload + except (AttributeError, KeyError, TypeError, ValueError): + raise IntervalReceiptBlocked("interval_receipt_plan_invalid") from None + + +def build_interval_receipt_plan(*, ledger_path, source_ledger_update_time, + previous_state, next_state, interval): + """Bind an ALREADY verified forward transition to its exact interval. + + Caller retains responsibility for the existing conservation, cursor history, + fill provenance, price sampling and no-order gates. This helper does not + reconstruct evidence or establish those facts from balances/daily totals. + """ + _validate_path(ledger_path) + payload, start, end, logical_id = _payload_material(interval) + scope = payload["account_scope_sha256"] + if not isinstance(previous_state, Mapping) or not isinstance(next_state, Mapping): + raise IntervalReceiptBlocked("interval_receipt_state_invalid") + old, new = copy.deepcopy(dict(previous_state)), copy.deepcopy(dict(next_state)) + try: + before, after = old["earn_accrual_checkpoint"], new["earn_accrual_checkpoint"] + if (before["account_scope_sha256"] != scope or after["account_scope_sha256"] != scope + or before["execution_authority_granted"] is not False + or after["execution_authority_granted"] is not False + or _time(before["observed_at"]) != start or _time(after["observed_at"]) != end + or _time(old["external_cash_flow_cursor"]["observed_at"]) != start + or _time(new["external_cash_flow_cursor"]["observed_at"]) != end + or not isinstance(before["assets"], Mapping) or not before["assets"] + or set(before["assets"]) != set(after["assets"]) + or new["earn_accounted_net_changes"] != {asset: "0" for asset in before["assets"]} + or old["order_submission"]["state"] not in {"RESERVED", "TERMINAL"}): + raise ValueError + changed = {key for key in set(old) | set(new) if key not in old or key not in new + or _json(old[key]) != _json(new[key])} + if not changed <= _PATCH_FIELDS or any(key not in new for key in _PATCH_FIELDS): + raise ValueError + except (KeyError, TypeError, ValueError): + raise IntervalReceiptBlocked("interval_receipt_transition_invalid") from None + patch = {key: new[key] for key in _PATCH_FIELDS} + # This patch preserves all unrelated fields, including future audit/fill keys. + if _json({**old, **patch}) != _json(new): + raise IntervalReceiptBlocked("interval_receipt_transition_invalid") + receipt_path = ledger_path + "__interval_receipt_" + logical_id + source_version = _version(source_ledger_update_time) + before_digest, after_digest, payload_digest = _digest(old), _digest(new), _digest(payload) + receipt = _receipt_material(ledger_path=ledger_path, source_version=source_version, + before_digest=before_digest, after_digest=after_digest, + patch=patch, payload=payload) + return IntervalReceiptPlan(ledger_path, receipt_path, source_version, before_digest, + after_digest, payload_digest, _json(patch), _json(receipt)) + + +def _read(transaction, *, ledger_ref, owner_ref, receipt_ref, owner_id): + # Firestore requires all reads before writes; all three are transaction reads. + owner = owner_ref.get(transaction=transaction, retry=None) + ledger = ledger_ref.get(transaction=transaction, retry=None) + receipt = receipt_ref.get(transaction=transaction, retry=None) + if (not owner.exists or not isinstance(owner.to_dict(), Mapping) + or owner.to_dict().get("owner_id") != owner_id or not ledger.exists): + raise IntervalReceiptBlocked("interval_receipt_owner_or_ledger_changed") + return ledger, receipt + + +def _same_backend(*, bound_client, database, refs, transaction=None): + """Minimal pinned Firestore-native identity check; never opens a client.""" + if (bound_client is None or not isinstance(database, str) + or not re.fullmatch(r"projects/[^/]+/databases/[^/]+", database) + or getattr(bound_client, "_database_string", None) != database + or (transaction is not None and getattr(transaction, "_client", None) is not bound_client)): + raise IntervalReceiptBlocked("interval_receipt_backend_mismatch") + for ref in refs: + if (getattr(ref, "_client", None) is not bound_client + or getattr(ref, "_document_path", None) != database + "/documents/" + ref.path): + raise IntervalReceiptBlocked("interval_receipt_backend_mismatch") + if transaction is not None and ( + type(getattr(transaction, "_max_attempts", None)) is not int + or transaction._max_attempts != 1 + ): + raise IntervalReceiptBlocked("interval_receipt_transaction_attempts_invalid") + + +def persist_interval_source_receipt(*, bound_client, transaction_runner, ledger_ref, owner_ref, + owner_id, expected_ledger_update_time, plan): + """Stage receipt create + checkpoint/cursor patch, then exact read-only readback. + + bound_client must be the existing state store's native client. The pinned + native _client / _database_string / _document_path seam is checked before + document reads. transaction_runner(callback) must execute one transaction, + with max_attempts=1, using that client. No implicit + client construction or backend selection is allowed. Unknown commit/readback + raises uncertainty; it is not a delivery ACK or proof of rollback. + + Retry uses the original frozen plan and a freshly observed ledger version. + Identical receipt plus exact planned poststate is a no-write replay. A later + progressed ledger fails closed here; archive queries remain a separate proposal. + """ + if (not isinstance(plan, IntervalReceiptPlan) or not callable(transaction_runner) + or not isinstance(owner_id, str) or not owner_id or owner_id != owner_id.strip() + or getattr(ledger_ref, "path", None) != plan.ledger_path + or getattr(owner_ref, "path", None) != plan.ledger_path + "__owner"): + raise IntervalReceiptBlocked("interval_receipt_reference_or_owner_invalid") + patch, payload = _validate_plan(plan) + expected_version = _version(expected_ledger_update_time) + database = getattr(bound_client, "_database_string", None) + _same_backend(bound_client=bound_client, database=database, refs=(ledger_ref, owner_ref)) + receipt_ref = ledger_ref.parent.document(plan.receipt_path.rsplit("/", 1)[1]) + if getattr(receipt_ref, "path", None) != plan.receipt_path: + raise IntervalReceiptBlocked("interval_receipt_reference_invalid") + refs = (ledger_ref, owner_ref, receipt_ref) + _same_backend(bound_client=bound_client, database=database, refs=refs) + + def stage(transaction): + _same_backend(bound_client=bound_client, database=database, refs=refs, transaction=transaction) + ledger, receipt = _read(transaction, ledger_ref=ledger_ref, owner_ref=owner_ref, + receipt_ref=receipt_ref, owner_id=owner_id) + if _version(ledger.update_time) != expected_version: + raise IntervalReceiptBlocked("interval_receipt_ledger_version_changed") + if receipt.exists: + if _json(receipt.to_dict()) != plan.receipt_json: + raise IntervalReceiptConflict("interval_receipt_existing_conflict") + if _digest(ledger.to_dict()) != plan.ledger_after_sha256: + raise IntervalReceiptBlocked("interval_receipt_replay_poststate_unverified") + if _json({key: ledger.to_dict()[key] for key in _PATCH_FIELDS}) != plan.patch_json: + raise IntervalReceiptBlocked("interval_receipt_replay_patch_unverified") + return "identical" + if (expected_version != plan.source_ledger_version + or _digest(ledger.to_dict()) != plan.ledger_before_sha256): + raise IntervalReceiptBlocked("interval_receipt_ledger_source_changed") + actual = ledger.to_dict() + regenerated = build_interval_receipt_plan( + ledger_path=plan.ledger_path, source_ledger_update_time=ledger.update_time, + previous_state=actual, next_state={**actual, **patch}, interval=payload, + ) + if regenerated != plan: + raise IntervalReceiptBlocked("interval_receipt_plan_source_mismatch") + transaction.create(receipt_ref, json.loads(plan.receipt_json)) + transaction.update(ledger_ref, patch) + return "created" + + try: + mode = transaction_runner(stage) + if mode not in {"created", "identical"}: + raise IntervalReceiptUncertain("interval_receipt_commit_uncertain") + except IntervalReceiptBlocked: + raise + except Exception: + raise IntervalReceiptUncertain("interval_receipt_commit_uncertain") from None + + def readback(transaction): + _same_backend(bound_client=bound_client, database=database, refs=refs, transaction=transaction) + ledger, receipt = _read(transaction, ledger_ref=ledger_ref, owner_ref=owner_ref, + receipt_ref=receipt_ref, owner_id=owner_id) + if (not receipt.exists or _json(receipt.to_dict()) != plan.receipt_json + or _digest(ledger.to_dict()) != plan.ledger_after_sha256): + raise IntervalReceiptBlocked("interval_receipt_readback_mismatch") + return True + + try: + if transaction_runner(readback) is not True: + raise IntervalReceiptUncertain("interval_receipt_readback_uncertain") + except Exception: + raise IntervalReceiptUncertain("interval_receipt_readback_uncertain") from None + return { + "status": "source_receipt_readback_verified", + "write_mode": mode, + "receipt_path": plan.receipt_path, + "payload_sha256": plan.payload_sha256, + "performance_delivery_acknowledged": False, + } diff --git a/docs/interval_source_receipt_candidate.md b/docs/interval_source_receipt_candidate.md new file mode 100644 index 00000000..ff2b6352 --- /dev/null +++ b/docs/interval_source_receipt_candidate.md @@ -0,0 +1,124 @@ +# Interval source receipts: LOCAL CANDIDATE + +## Decision + +The simpler source-retention seam is feasible in the existing Firestore-native +transaction API: create one per-interval receipt and patch the five existing +forward-accounting fields in the same transaction. A separate pending queue, +PerformanceStore delivery ACK and queue-clear write are unnecessary for that +bounded source-retention property. This is an isolated helper/fake-test candidate, +not runtime integration, provider verification or consumer qualification. + +Receipts are **create-once under this candidate API**, not storage-level immutable +or WORM. IAM, other writers, retention/PITR and physical production identity remain +unverified. Archival storage/read costs still grow and need a separate decision. + +## Verified source seams and precise gaps + +- Binance main `ab7daec02c3bf2e58ee5fa363c26c2124f7e497f` and source release + `8cb56617115fa45028e34d788e71884b6a303d77` have matching local/API Git blobs for + `live_services.py`, `runtime_support.py`, `trade_state_support.py` and + `scripts/migrate_daily_accounting_state.py`. The earlier interval review also + verified the producer/adapter and QPK pin `8e8ec51884bf8abb0a7ca699fc2da39fad134f89`. +- [Bound state access](https://github.com/QuantStrategyLab/BinancePlatform/blob/ab7daec02c3bf2e58ee5fa363c26c2124f7e497f/live_services.py#L48-L86) + closes load/save/claim/release over one store. However, its load returns only a + dict, losing snapshot update_time; its save is a blind `set`; the closure does + not expose a receipt transaction seam. Integration must retain the actual bound + client, refs and exact snapshot version, not call another provider factory. +- [Migration transaction](https://github.com/QuantStrategyLab/BinancePlatform/blob/ab7daec02c3bf2e58ee5fa363c26c2124f7e497f/scripts/migrate_daily_accounting_state.py#L599-L679) + already reads ledger/owner/archive before `create` + ledger `update`, then checks + exact digests after commit. Migration requires owner absent; this runtime-shaped + candidate instead requires the held owner ID inside both transactions. +- Release/main lockfiles pin native Firestore `2.28.0`. Its exact official source + exposes reference `_client` and `_document_path`, client `_database_string`, + and transaction `_client` / `_max_attempts`: [reference](https://github.com/googleapis/google-cloud-python/blob/google-cloud-firestore-v2.28.0/packages/google-cloud-firestore/google/cloud/firestore_v1/base_document.py#L137-L165), + [client](https://github.com/googleapis/google-cloud-python/blob/google-cloud-firestore-v2.28.0/packages/google-cloud-firestore/google/cloud/firestore_v1/base_client.py#L239-L257), + [transaction](https://github.com/googleapis/google-cloud-python/blob/google-cloud-firestore-v2.28.0/packages/google-cloud-firestore/google/cloud/firestore_v1/base_transaction.py#L65-L70). + Candidate checks that private, version-specific seam explicitly and fails closed + if missing. SDK upgrades require re-verification; no native client was created. +- [Pinned DocumentStore](https://github.com/QuantStrategyLab/QuantPlatformKit/blob/8e8ec51884bf8abb0a7ca699fc2da39fad134f89/src/quant_platform_kit/cloud/ports.py#L108-L146) + has no receipt enumeration/query contract. The pinned GCP implementation exposes + a native client, but that does not establish archive pagination, completeness, + ordering, authorization, indexes or cost. No archive reader is implemented. + +## Candidate contract + +`application/interval_source_receipt_candidate.py` has no provider imports, +backend defaults, client construction, normal runtime registration or delivery. +The caller supplies the existing bound native client, refs, held owner ID, exact +observed ledger update_time, and a transaction runner. Both callbacks require +`max_attempts=1`; all document reads precede writes. + +1. Accept only the existing exact seven-field interval, unchanged. Hash canonical + JSON of that exact payload. Logical receipt ID hashes scope plus normalized UTC + start/end; payload hash and source/patch metadata stay outside the payload. + Same logical ID with changed payload/metadata is a conflict, never a replacement. +2. Freeze serialized plan material, then independently revalidate it before any + transaction: schema, logical ID/path, payload hash, source digest/version + bindings, canonical JSON and exact five-field patch hash. Frozen dataclasses + alone are not a trust boundary; constructible/replaced plans are checked. +3. Before any document `get`, require ledger/owner/receipt/transaction to use the + exact same bound client object and fully qualified database/document paths. + Relative paths alone are insufficient. This checks local binding, not an + attestation of the intended production database or its external policies. +4. Transactionally verify matching owner and full-precision snapshot version. + Version token preserves seconds plus all nanoseconds; ordinary datetime + equality/isoformat can discard a Firestore sub-microsecond tail. For create, + require the actual pre-ledger digest and regenerate the complete expected plan + from that transactional ledger, allowed patch and interval before any write. +5. `create(receipt)` and `update(ledger, five-field patch)` commit together. Only + checkpoint, cash-flow cursor, accounted net changes, last balance snapshot and + daily external principal are eligible. Unrelated audit/fill/fee fields remain + untouched. This API has no receipt `set`, overwrite, truncation or deletion. +6. A second transaction performs exact receipt + post-ledger readback while the + owner still matches. Commit/readback exceptions or mismatches raise a fixed + uncertain result. They never establish an ACK or prove rollback. An explicit + retry uses the original plan plus a freshly observed current version; exact + receipt/poststate/patch match is a no-write replay, so principal/fees are not + applied again. A progressed later ledger fails closed in this helper; old + receipt retention remains independent and a future reader can inspect it. + +The caller must already have passed the existing `prepare_forward_earn_state` +conservation, cursor-history, known-fill, price-sampling, enabled/non-dry-run and +order-certainty gates. The helper checks structural binding, not those business +facts or provider-history coverage. It does not implement withdrawals, expand +account scope, invent historical intervals, reconstruct principal from daily +totals or grant trading authority. + +## Reader proposal and stopping point + +A separately reviewed read-only reader could return each verified receipt's raw +seven-field `interval` to the [existing QPK interval calculator](https://github.com/QuantStrategyLab/QuantPlatformKit/blob/8e8ec51884bf8abb0a7ca699fc2da39fad134f89/src/quant_platform_kit/strategy_lifecycle/live_equity.py#L294-L482). +That requires a proven archive query/pagination/order contract, scope and cutover +coverage, corruption/conflict handling, access and cost limits, and consumer +adoption tests. Neither this proposal nor existing QPK parsing proves coverage. +No current recorder/monitor starts reading these candidate receipts automatically. + +Future integration needs a reviewed opt-in writer/version seam in the same bound +state backend before the current checkpoint write. Existing ordinary saves remain +blind `set`: enabling this helper alone does not fence concurrent/legacy writers +or prevent a later stale ledger overwrite. Mixed-writer rollout, owner lifecycle, +and bounded native RPC timeout/attempt configuration require integration review +before adoption. Existing post-owner-release best-effort recording must not become +the source ACK. Backend identity, IAM, +retention, storage size/cost and historical completeness remain open decisions. +No queue-capacity/trading-stop policy or archival retention policy was selected. + +## Local verification + +- 37 dependency-free fake-store tests passed; focused Ruff and py_compile passed +- Tests cover atomic rollback including second-write failure, owner/version races, + sub-microsecond CAS, mixed backends before reads, create/identical/conflict, + lost commit response, failed/conflicting readback, malformed and replaced plans, + source regeneration, exact retry without principal/fee re-accounting, normalizer + reload and retained earlier receipts after later progress/failure +- Plan-integrity regression was reproduced RED with `dataclasses.replace`, then + fixed and verified; it was not covered by the first fake-test pass +- Only this new helper, its new test file and this document differ from the + isolated baseline. Existing live services, runtime writer, normalizer, workflows, + pins and default entrypoints are byte-for-byte unchanged +- No real Firestore/GCP/broker/provider calls, credentials, business data, costs, + SDK client construction, code commit, publication, deployment or consumer + qualification. Read-only source retrieval is separate from fake execution + +Run: `python3 -m unittest discover -s tests -p test_interval_source_receipt_candidate.py -v` diff --git a/tests/test_interval_source_receipt_candidate.py b/tests/test_interval_source_receipt_candidate.py new file mode 100644 index 00000000..a0213458 --- /dev/null +++ b/tests/test_interval_source_receipt_candidate.py @@ -0,0 +1,710 @@ +"""LOCAL CANDIDATE only: atomic source retention against an in-memory store.""" + +import copy +import json +import socket +import unittest +from dataclasses import replace +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +from application.interval_source_receipt_candidate import ( + IntervalReceiptBlocked, + IntervalReceiptConflict, + IntervalReceiptUncertain, + build_interval_receipt_plan, + persist_interval_source_receipt, +) + + +START = "2026-10-03T23:59:00+00:00" +END = "2026-10-04T00:00:00+00:00" +VERSION = datetime(2026, 10, 3, 23, 59, 1, tzinfo=timezone.utc) +LEDGER = "strategy/MULTI_ASSET_STATE" +OWNER = LEDGER + "__owner" + + +def material(start=START, end=END, quantity="1100", flow="100"): + previous = { + "earn_accrual_checkpoint": { + "account_scope_sha256": "a" * 64, + "observed_at": start, + "assets": {"USDT": {"quantity": "1000"}}, + "execution_authority_granted": False, + }, + "external_cash_flow_cursor": {"observed_at": start, "records": []}, + "earn_accounted_net_changes": {"USDT": "0"}, + "last_balance_snapshot": {"USDT": 1000.0}, + "last_reset_date": "2026-10-04", + "daily_external_principal_usdt": 0.0, + "order_submission": {"state": "TERMINAL", "fill_accounted": True}, + "daily_trend_third_fee_usdt": 0.5, + "unknown_future_field": {"keep": True}, + } + following = copy.deepcopy(previous) + following["earn_accrual_checkpoint"]["observed_at"] = end + following["earn_accrual_checkpoint"]["assets"]["USDT"]["quantity"] = quantity + following["external_cash_flow_cursor"] = { + "observed_at": end, "records": [{"synthetic_deposit_id": "fixed"}] + } + following["last_balance_snapshot"] = {"USDT": float(quantity)} + following["daily_external_principal_usdt"] = float(flow) + interval = { + "account_scope_sha256": "a" * 64, + "start_at": start, + "end_at": end, + "end_equity_usdt": quantity, + "net_external_cash_flow": flow, + "currency": "USDT", + "valuation_basis": "checkpoint_quantities_sampled_prices", + } + return previous, following, interval + + +class Snapshot: + def __init__(self, value, version): + self.exists = value is not None + self.update_time = version + self.value = copy.deepcopy(value) + + def to_dict(self): + return copy.deepcopy(self.value) + + +class NanosecondVersion(datetime): + """Matches the SDK's separately stored nanos and datetime comparison trap.""" + + def __new__(cls, *args, nanosecond=0, **kwargs): + if nanosecond: + kwargs["microsecond"] = nanosecond // 1000 + result = super().__new__(cls, *args, **kwargs) + result.nanosecond = nanosecond + return result + + +class Collection: + def __init__(self, store, path): + self.store, self.path = store, path + + def document(self, name): + return Ref(self.store, self.path + "/" + name) + + +class Ref: + def __init__(self, store, path): + self.store, self.path = store, path + self._client = store + self.id = path.rsplit("/", 1)[1] + self.parent = Collection(store, path.rsplit("/", 1)[0]) + + @property + def _document_path(self): + return self._client._database_string + "/documents/" + self.path + + def get(self, *, transaction, retry): + self.store.read_calls += 1 + assert retry is None + assert transaction.store is self.store + if transaction.operations: + raise AssertionError("reads must precede writes") + transaction.reads.add(self.path) + return Snapshot(transaction.values.get(self.path), transaction.versions.get(self.path)) + + +class Transaction: + def __init__(self, store): + self.store = store + self._client = store + self._max_attempts = 1 + self.values = copy.deepcopy(store.values) + # Native timestamp objects are treated as immutable version tokens. + # datetime-subclass deepcopy can discard separately stored nanoseconds. + self.versions = store.versions.copy() + self.reads = set() + self.operations = [] + + def create(self, ref, value): + self.operations.append(("create", ref.path, copy.deepcopy(value))) + + def update(self, ref, patch): + self.operations.append(("update", ref.path, copy.deepcopy(patch))) + + +class AtomicStore: + """Models all-or-nothing commit, read versions, and ambiguous transport.""" + + def __init__(self, previous): + self._database_string = "projects/synthetic-project/databases/synthetic-database" + self.read_calls = 0 + self.values = {LEDGER: copy.deepcopy(previous), OWNER: {"owner_id": "owner-one"}} + self.versions = {LEDGER: VERSION, OWNER: VERSION} + self.transactions = [] + self.failure = None + self.before_commit = None + self.after_commit = None + self.clock = VERSION + + def run(self, callback): + transaction = Transaction(self) + result = callback(transaction) + self.transactions.append(transaction) + if self.before_commit: + hook, self.before_commit = self.before_commit, None + hook(self) + def version_identity(value): + if value is None: + return None + return (value.year, value.month, value.day, value.hour, value.minute, value.second, + getattr(value, "nanosecond", 0) or value.microsecond * 1000) + if any(version_identity(self.versions.get(path)) != version_identity(transaction.versions.get(path)) + for path in transaction.reads): + raise RuntimeError("synthetic optimistic conflict") + if transaction.operations and self.failure == "before_commit": + self.failure = None + raise RuntimeError("synthetic write transport failure") + candidate = copy.deepcopy(self.values) + for operation, path, value in transaction.operations: + if operation == "create": + if path in candidate: + raise RuntimeError("synthetic already exists") + candidate[path] = value + else: + if path not in candidate: + raise RuntimeError("synthetic missing ledger") + if self.failure == "during_update": + self.failure = None + raise RuntimeError("synthetic second write rejection") + candidate[path].update(value) + if transaction.operations: + self.clock += timedelta(microseconds=1) + self.values = candidate + for _, path, _ in transaction.operations: + self.versions[path] = self.clock + if self.after_commit: + hook, self.after_commit = self.after_commit, None + hook(self) + if self.failure == "after_commit": + self.failure = None + raise RuntimeError("synthetic lost commit response") + elif self.failure == "readback": + self.failure = None + raise RuntimeError("synthetic readback failure") + return result + + def ref(self, path): + return Ref(self, path) + + def external_change(self, path, value): + self.clock += timedelta(microseconds=1) + self.values[path] = copy.deepcopy(value) + self.versions[path] = self.clock + + +class IntervalSourceReceiptTests(unittest.TestCase): + def setUp(self): + self.previous, self.following, self.interval = material() + self.store = AtomicStore(self.previous) + self.plan = self.build() + + def build(self, **changes): + args = dict( + ledger_path=LEDGER, + source_ledger_update_time=VERSION, + previous_state=self.previous, + next_state=self.following, + interval=self.interval, + ) + args.update(changes) + return build_interval_receipt_plan(**args) + + def persist(self, *, plan=None, owner="owner-one", version=None): + return persist_interval_source_receipt( + bound_client=self.store, + transaction_runner=self.store.run, + ledger_ref=self.store.ref(LEDGER), + owner_ref=self.store.ref(OWNER), + owner_id=owner, + expected_ledger_update_time=version or self.store.versions[LEDGER], + plan=plan or self.plan, + ) + + def receipt(self): + return self.store.values[self.plan.receipt_path] + + def test_receipt_checkpoint_and_cursor_commit_in_one_transaction(self): + result = self.persist() + operations = self.store.transactions[0].operations + self.assertEqual([op[0] for op in operations], ["create", "update"]) + self.assertEqual(operations[0][1], self.plan.receipt_path) + self.assertEqual(self.store.values[LEDGER], self.following) + self.assertEqual(self.receipt()["interval"], self.interval) + self.assertEqual(len(self.receipt()["interval"]), 7) + self.assertEqual(result["status"], "source_receipt_readback_verified") + self.assertIs(result["performance_delivery_acknowledged"], False) + + def test_write_failure_rolls_back_both_records(self): + for failure in ("before_commit", "during_update"): + with self.subTest(failure=failure): + self.store.failure = failure + with self.assertRaises(IntervalReceiptUncertain): + self.persist() + self.assertEqual(self.store.values[LEDGER], self.previous) + self.assertNotIn(self.plan.receipt_path, self.store.values) + + def test_exact_duplicate_retry_never_reaccounts_principal_or_fee(self): + self.persist() + ledger_version = self.store.versions[LEDGER] + original_receipt = copy.deepcopy(self.receipt()) + result = self.persist() + self.assertEqual(result["write_mode"], "identical") + self.assertEqual(self.store.transactions[-2].operations, []) + self.assertEqual(self.store.versions[LEDGER], ledger_version) + self.assertEqual(self.store.values[LEDGER]["daily_external_principal_usdt"], 100.0) + self.assertEqual(self.store.values[LEDGER]["daily_trend_third_fee_usdt"], 0.5) + self.assertEqual(self.receipt(), original_receipt) + + def test_lost_commit_response_is_uncertain_then_fresh_version_retry_is_identical(self): + self.store.failure = "after_commit" + with self.assertRaises(IntervalReceiptUncertain): + self.persist() + self.assertEqual(self.store.values[LEDGER], self.following) + self.assertEqual(self.receipt()["interval"], self.interval) + with self.assertRaises(IntervalReceiptBlocked): + self.persist(version=VERSION) + self.assertEqual(self.persist()["write_mode"], "identical") + + def test_failed_readback_is_uncertain_and_source_is_retained(self): + self.store.failure = "readback" + with self.assertRaises(IntervalReceiptUncertain): + self.persist() + self.assertEqual(self.receipt()["interval"], self.interval) + self.assertEqual(self.persist()["write_mode"], "identical") + + def test_conflicting_payload_has_same_identity_and_cannot_overwrite(self): + self.persist() + changed_interval = {**self.interval, "end_equity_usdt": "1101"} + conflict = self.build(interval=changed_interval) + self.assertEqual(conflict.receipt_path, self.plan.receipt_path) + original = copy.deepcopy(self.receipt()) + with self.assertRaises(IntervalReceiptConflict): + self.persist(plan=conflict) + self.assertEqual(self.receipt(), original) + self.assertEqual(self.store.values[LEDGER], self.following) + + def test_readback_payload_conflict_never_returns_confirmation(self): + def corrupt(store): + receipt = copy.deepcopy(store.values[self.plan.receipt_path]) + receipt["interval"]["end_equity_usdt"] = "9999" + store.external_change(self.plan.receipt_path, receipt) + self.store.after_commit = corrupt + with self.assertRaises(IntervalReceiptUncertain): + self.persist() + with self.assertRaises(IntervalReceiptConflict): + self.persist() + self.assertEqual(self.receipt()["interval"]["end_equity_usdt"], "9999") + + def test_readback_ledger_conflict_never_claims_retention_pair_confirmed(self): + self.store.after_commit = lambda store: store.external_change( + LEDGER, {**self.following, "daily_external_principal_usdt": 999.0} + ) + with self.assertRaises(IntervalReceiptUncertain): + self.persist() + self.assertEqual(self.receipt()["interval"], self.interval) + with self.assertRaises(IntervalReceiptBlocked): + self.persist() + + def test_owner_missing_or_wrong_blocks_before_writes(self): + for owner_value in (None, {"owner_id": "other-owner"}): + with self.subTest(owner=owner_value): + self.store.values[OWNER] = owner_value + with self.assertRaises(IntervalReceiptBlocked): + self.persist() + self.assertEqual(self.store.values[LEDGER], self.previous) + self.assertNotIn(self.plan.receipt_path, self.store.values) + + def test_owner_changes_before_commit_atomically_abort(self): + self.store.before_commit = lambda store: store.external_change(OWNER, {"owner_id": "other-owner"}) + with self.assertRaises(IntervalReceiptUncertain): + self.persist() + self.assertEqual(self.store.values[LEDGER], self.previous) + self.assertNotIn(self.plan.receipt_path, self.store.values) + + def test_owner_changes_after_commit_readback_is_uncertain(self): + self.store.after_commit = lambda store: store.external_change(OWNER, {"owner_id": "other-owner"}) + with self.assertRaises(IntervalReceiptUncertain): + self.persist() + self.assertEqual(self.receipt()["interval"], self.interval) + + def test_stale_version_or_same_version_changed_digest_blocks(self): + with self.assertRaises(IntervalReceiptBlocked): + self.persist(version=VERSION - timedelta(seconds=1)) + self.store.values[LEDGER]["unknown_future_field"] = {"keep": False} + with self.assertRaises(IntervalReceiptBlocked): + self.persist() + self.assertNotIn(self.plan.receipt_path, self.store.values) + + def test_version_cas_preserves_submicrosecond_firestore_precision(self): + before = NanosecondVersion(2026, 10, 3, 23, 59, 1, nanosecond=123456701, tzinfo=timezone.utc) + changed = NanosecondVersion(2026, 10, 3, 23, 59, 1, nanosecond=123456702, tzinfo=timezone.utc) + self.assertEqual(before, changed) # Ordinary datetime equality drops the tail. + self.assertEqual(before.isoformat(), changed.isoformat()) + self.store.versions[LEDGER] = before + plan = self.build(source_ledger_update_time=before) + with self.assertRaises(IntervalReceiptBlocked): + self.persist(plan=plan, version=changed) + self.assertNotIn(plan.receipt_path, self.store.values) + self.store.clock = before + self.assertEqual(self.persist(plan=plan, version=before)["write_mode"], "created") + self.assertTrue(self.receipt()["source"]["ledger_before_version"].endswith(".123456701Z")) + + def test_submicrosecond_version_change_before_commit_atomically_aborts(self): + before = NanosecondVersion(2026, 10, 3, 23, 59, 1, nanosecond=123456701, tzinfo=timezone.utc) + changed = NanosecondVersion(2026, 10, 3, 23, 59, 1, nanosecond=123456702, tzinfo=timezone.utc) + self.store.versions[LEDGER] = before + self.store.before_commit = lambda store: store.versions.update({LEDGER: changed}) + plan = self.build(source_ledger_update_time=before) + with self.assertRaises(IntervalReceiptUncertain): + self.persist(plan=plan, version=before) + self.assertEqual(self.store.values[LEDGER], self.previous) + self.assertNotIn(plan.receipt_path, self.store.values) + + def test_source_version_and_current_version_must_both_match_on_create(self): + advanced = VERSION + timedelta(seconds=1) + self.store.versions[LEDGER] = advanced + with self.assertRaises(IntervalReceiptBlocked): + self.persist(version=advanced) # Current matches, original source version does not. + self.assertEqual(self.store.values[LEDGER], self.previous) + self.assertNotIn(self.plan.receipt_path, self.store.values) + + def test_concurrent_receipt_creation_aborts_without_overwrite(self): + self.store.before_commit = lambda store: store.external_change( + self.plan.receipt_path, {"synthetic_conflicting_receipt": True} + ) + with self.assertRaises(IntervalReceiptUncertain): + self.persist() + self.assertEqual(self.store.values[LEDGER], self.previous) + self.assertEqual(self.receipt(), {"synthetic_conflicting_receipt": True}) + with self.assertRaises(IntervalReceiptConflict): + self.persist() + + def test_ledger_changes_before_commit_atomically_abort(self): + self.store.before_commit = lambda store: store.external_change(LEDGER, {**self.previous, "changed": True}) + with self.assertRaises(IntervalReceiptUncertain): + self.persist() + self.assertNotIn(self.plan.receipt_path, self.store.values) + self.assertEqual(self.store.values[LEDGER]["earn_accrual_checkpoint"], self.previous["earn_accrual_checkpoint"]) + + def test_later_cycle_and_performance_delivery_failures_do_not_remove_source(self): + self.persist() + for error in ("synthetic later cycle failure", "synthetic PerformanceStore unavailable"): + try: + raise RuntimeError(error) + except RuntimeError: + pass + self.assertEqual(self.receipt()["interval"], self.interval) + self.assertEqual(self.store.values[LEDGER], self.following) + + def test_normalizer_reload_has_no_queue_or_receipt_field_to_drop(self): + from trade_state_support import normalize_trade_state + self.persist() + retained = copy.deepcopy(self.receipt()) + reloaded = normalize_trade_state( + self.store.values[LEDGER], trend_universe=(), last_good_payload_key="last_good", + action_history_key="actions", retired_positions_key="retired", + ) + self.assertEqual(reloaded["earn_accrual_checkpoint"], self.following["earn_accrual_checkpoint"]) + self.assertEqual(reloaded["external_cash_flow_cursor"], self.following["external_cash_flow_cursor"]) + self.assertFalse(any("receipt" in key or "outbox" in key for key in reloaded)) + self.assertEqual(self.receipt(), retained) + + def test_module_execution_has_no_socket_or_performance_store_dependency(self): + with patch.object(socket, "socket", side_effect=AssertionError("network is prohibited")): + self.persist() + self.persist() + self.assertEqual(self.receipt()["interval"], self.interval) + + def test_next_interval_retains_previous_receipt_and_legacy_state_is_not_fabricated(self): + self.assertFalse(any("__interval_receipt_" in path for path in self.store.values)) + self.persist() + first = copy.deepcopy(self.receipt()) + next_state = copy.deepcopy(self.following) + next_state["earn_accrual_checkpoint"]["observed_at"] = "2026-10-04T00:01:00+00:00" + next_state["external_cash_flow_cursor"]["observed_at"] = "2026-10-04T00:01:00+00:00" + next_interval = { + **self.interval, + "start_at": END, + "end_at": "2026-10-04T00:01:00+00:00", + "net_external_cash_flow": "0", + } + plan = self.build( + previous_state=self.following, next_state=next_state, + interval=next_interval, source_ledger_update_time=self.store.versions[LEDGER], + ) + self.persist(plan=plan) + self.assertEqual(self.receipt(), first) + self.assertIn(plan.receipt_path, self.store.values) + self.assertEqual(len([path for path in self.store.values if "__interval_receipt_" in path]), 2) + with self.assertRaises(IntervalReceiptBlocked): + self.persist() # Old poststate is no longer current; no rewrite or guessed ancestry. + self.assertEqual(self.receipt(), first) + + def test_plan_is_detached_from_input_mutation_and_preserves_unrelated_fields(self): + self.interval["end_equity_usdt"] = "9999" + self.following["unknown_future_field"]["keep"] = False + self.persist() + self.assertEqual(self.receipt()["interval"]["end_equity_usdt"], "1100") + self.assertEqual(self.store.values[LEDGER]["unknown_future_field"], {"keep": True}) + self.assertEqual(self.store.values[LEDGER]["order_submission"], self.previous["order_submission"]) + + def test_logical_identity_uses_normalized_times_but_exact_payload_hash_differs(self): + equivalent = {**self.interval, "start_at": START.replace("+00:00", "Z")} + plan = self.build(interval=equivalent) + self.assertEqual(plan.receipt_path, self.plan.receipt_path) + self.assertNotEqual(plan.payload_sha256, self.plan.payload_sha256) + + def test_bad_interval_scope_times_numbers_or_metadata_fail_closed(self): + invalid = [ + {**self.interval, "extra": True}, + {**self.interval, "account_scope_sha256": "b" * 64}, + {**self.interval, "end_at": START}, + {**self.interval, "start_at": "2026-10-03T23:59:00"}, + {**self.interval, "start_at": "2026-10-03T23:59:00.1234567Z"}, + {**self.interval, "end_equity_usdt": "NaN"}, + {**self.interval, "end_equity_usdt": "1e1000000"}, + {**self.interval, "end_equity_usdt": "0"}, + {**self.interval, "net_external_cash_flow": True}, + {**self.interval, "currency": "BTC"}, + {**self.interval, "valuation_basis": "guessed"}, + ] + for interval in invalid: + with self.subTest(interval=interval): + with self.assertRaises(IntervalReceiptBlocked): + self.build(interval=interval) + self.assertEqual(self.store.values[LEDGER], self.previous) + + def test_checkpoint_cursor_scope_unsettled_and_unrelated_state_changes_fail_closed(self): + for mutate in ( + lambda state: state["external_cash_flow_cursor"].update(observed_at=START), + lambda state: state["earn_accrual_checkpoint"].update(account_scope_sha256="b" * 64), + lambda state: state.update(daily_trend_third_fee_usdt=2), + lambda state: state.pop("unknown_future_field"), + lambda state: state["earn_accounted_net_changes"].update(USDT="1"), + ): + next_state = copy.deepcopy(self.following) + mutate(next_state) + with self.assertRaises(IntervalReceiptBlocked): + self.build(next_state=next_state) + for status in ("SUBMISSION_UNKNOWN", "FILLED_ACCOUNTING_PENDING"): + previous = copy.deepcopy(self.previous) + following = copy.deepcopy(self.following) + previous["order_submission"]["state"] = status + following["order_submission"]["state"] = status + with self.assertRaises(IntervalReceiptBlocked): + self.build(previous_state=previous, next_state=following) + + def test_wrong_owner_reference_or_ledger_path_never_runs_transaction(self): + with self.assertRaises(IntervalReceiptBlocked): + persist_interval_source_receipt( + bound_client=self.store, + transaction_runner=self.store.run, + ledger_ref=self.store.ref(LEDGER), owner_ref=self.store.ref(LEDGER + "__wrong"), + owner_id="owner-one", expected_ledger_update_time=VERSION, plan=self.plan, + ) + self.assertEqual(self.store.transactions, []) + + def test_broken_runner_cannot_skip_readback_and_claim_confirmation(self): + calls = [] + def broken(callback): + calls.append(callback) + return self.store.run(callback) if len(calls) == 1 else None + with self.assertRaises(IntervalReceiptUncertain): + persist_interval_source_receipt( + bound_client=self.store, + transaction_runner=broken, ledger_ref=self.store.ref(LEDGER), + owner_ref=self.store.ref(OWNER), owner_id="owner-one", + expected_ledger_update_time=VERSION, plan=self.plan, + ) + self.assertEqual(self.receipt()["interval"], self.interval) + + def test_replaced_plan_cannot_write_unrelated_or_inconsistent_material(self): + injected = json.loads(self.plan.patch_json) + injected["unapproved_authority_field"] = True + altered_allowed = json.loads(self.plan.patch_json) + altered_allowed["daily_external_principal_usdt"] = 999.0 + receipt = json.loads(self.plan.receipt_json) + receipt["interval"]["end_equity_usdt"] = "9999" + def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":")) + for forged in ( + replace(self.plan, patch_json=canonical(injected)), + replace(self.plan, patch_json=canonical(altered_allowed)), + replace(self.plan, receipt_json=canonical(receipt)), + replace(self.plan, ledger_before_sha256="b" * 64), + replace(self.plan, ledger_after_sha256="b" * 64), + replace(self.plan, payload_sha256="b" * 64), + replace(self.plan, receipt_path=LEDGER + "__wrong_receipt"), + ): + with self.subTest(forged=forged): + self.store = AtomicStore(self.previous) + failure = None + try: + self.persist(plan=forged) + except (IntervalReceiptBlocked, IntervalReceiptUncertain) as exc: + failure = exc + self.assertEqual(self.store.values[LEDGER], self.previous) + self.assertEqual(set(self.store.values), {LEDGER, OWNER}) + self.assertIsInstance(failure, IntervalReceiptBlocked) + self.assertEqual(self.store.transactions, []) + + def test_same_relative_paths_on_mixed_clients_fail_before_any_read(self): + other = AtomicStore(self.previous) + other._database_string = "projects/other-project/databases/other-database" + for target in ("ledger", "owner", "receipt", "transaction"): + with self.subTest(target=target): + self.store.read_calls = other.read_calls = 0 + ledger_ref, owner_ref = self.store.ref(LEDGER), self.store.ref(OWNER) + runner = self.store.run + if target == "ledger": + ledger_ref = other.ref(LEDGER) + elif target == "owner": + owner_ref = other.ref(OWNER) + elif target == "receipt": + ledger_ref.parent = Collection(other, "strategy") + else: + runner = other.run + with self.assertRaises(IntervalReceiptBlocked): + persist_interval_source_receipt( + bound_client=self.store, transaction_runner=runner, + ledger_ref=ledger_ref, owner_ref=owner_ref, owner_id="owner-one", + expected_ledger_update_time=VERSION, plan=self.plan, + ) + self.assertEqual(self.store.read_calls + other.read_calls, 0) + self.assertEqual(self.store.values[LEDGER], self.previous) + self.assertNotIn(self.plan.receipt_path, self.store.values) + self.assertNotIn(self.plan.receipt_path, other.values) + + def test_equivalent_database_separate_client_and_wrong_full_path_are_rejected(self): + other = AtomicStore(self.previous) # Same database string, still not bound object. + with self.assertRaises(IntervalReceiptBlocked): + persist_interval_source_receipt( + bound_client=self.store, transaction_runner=self.store.run, + ledger_ref=self.store.ref(LEDGER), owner_ref=other.ref(OWNER), owner_id="owner-one", + expected_ledger_update_time=VERSION, plan=self.plan, + ) + class WrongFullPath(Ref): + @property + def _document_path(self): + return "projects/other-project/databases/other-database/documents/" + self.path + with self.assertRaises(IntervalReceiptBlocked): + persist_interval_source_receipt( + bound_client=self.store, transaction_runner=self.store.run, + ledger_ref=WrongFullPath(self.store, LEDGER), owner_ref=self.store.ref(OWNER), + owner_id="owner-one", expected_ledger_update_time=VERSION, plan=self.plan, + ) + self.assertEqual(self.store.read_calls + other.read_calls, 0) + + def test_database_changes_before_callback_are_rejected_before_reads(self): + def wrong_database(callback): + self.store._database_string = "projects/synthetic-project/databases/switched-database" + return self.store.run(callback) + with self.assertRaises(IntervalReceiptBlocked): + persist_interval_source_receipt( + bound_client=self.store, transaction_runner=wrong_database, + ledger_ref=self.store.ref(LEDGER), owner_ref=self.store.ref(OWNER), owner_id="owner-one", + expected_ledger_update_time=VERSION, plan=self.plan, + ) + self.assertEqual(self.store.read_calls, 0) + self.assertNotIn(self.plan.receipt_path, self.store.values) + + def test_readback_mixed_backend_is_uncertain_before_read_and_source_is_retained(self): + other = AtomicStore(self.previous) + calls = [] + def mixed(callback): + calls.append(callback) + return self.store.run(callback) if len(calls) == 1 else other.run(callback) + with self.assertRaises(IntervalReceiptUncertain): + persist_interval_source_receipt( + bound_client=self.store, transaction_runner=mixed, + ledger_ref=self.store.ref(LEDGER), owner_ref=self.store.ref(OWNER), owner_id="owner-one", + expected_ledger_update_time=VERSION, plan=self.plan, + ) + self.assertEqual(self.store.read_calls, 3) + self.assertEqual(other.read_calls, 0) + self.assertEqual(self.receipt()["interval"], self.interval) + + def test_fully_rebound_forgery_is_regenerated_against_transactional_source(self): + # Hashes can be recomputed by a caller. A fabricated before-state must + # still fail against the actual ledger before create/update are staged. + forged_previous = copy.deepcopy(self.previous) + forged_previous["unknown_future_field"]["keep"] = False + forged_next = copy.deepcopy(self.following) + forged_next["unknown_future_field"]["keep"] = False + forged = self.build(previous_state=forged_previous, next_state=forged_next) + with self.assertRaises(IntervalReceiptBlocked): + self.persist(plan=forged) + self.assertEqual(self.store.values[LEDGER], self.previous) + self.assertNotIn(self.plan.receipt_path, self.store.values) + self.assertEqual(self.store.read_calls, 3) + + def test_identical_retry_rejects_altered_patch_before_reads_or_reaccounting(self): + self.persist() + saved = copy.deepcopy(self.store.values) + count = self.store.read_calls + patch = json.loads(self.plan.patch_json) + patch["daily_external_principal_usdt"] = 999.0 + forged = replace(self.plan, patch_json=json.dumps(patch, sort_keys=True, separators=(",", ":"))) + with self.assertRaises(IntervalReceiptBlocked): + self.persist(plan=forged) + self.assertEqual(self.store.values, saved) + self.assertEqual(self.store.read_calls, count) + + def test_unbounded_or_missing_transaction_attempt_contract_fails_before_reads(self): + for attempts in (None, 5, True): + with self.subTest(attempts=attempts): + def invalid_runner(callback): + transaction = Transaction(self.store) + transaction._max_attempts = attempts + return callback(transaction) + with self.assertRaises(IntervalReceiptBlocked): + persist_interval_source_receipt( + bound_client=self.store, transaction_runner=invalid_runner, + ledger_ref=self.store.ref(LEDGER), owner_ref=self.store.ref(OWNER), + owner_id="owner-one", expected_ledger_update_time=VERSION, plan=self.plan, + ) + self.assertEqual(self.store.read_calls, 0) + self.assertNotIn(self.plan.receipt_path, self.store.values) + + def test_coherently_rebound_after_digest_is_compared_with_actual_next_state(self): + receipt = json.loads(self.plan.receipt_json) + receipt["source"]["ledger_after_sha256"] = "b" * 64 + forged = replace( + self.plan, ledger_after_sha256="b" * 64, + receipt_json=json.dumps(receipt, sort_keys=True, separators=(",", ":")), + ) + with self.assertRaises(IntervalReceiptBlocked): + self.persist(plan=forged) + self.assertEqual(self.store.values[LEDGER], self.previous) + self.assertNotIn(self.plan.receipt_path, self.store.values) + self.assertEqual(self.store.read_calls, 3) + + def test_unknown_schema_malformed_json_and_patch_binding_fail_before_reads(self): + for mutate in ( + lambda receipt: receipt.update(schema_version="candidate.v999"), + lambda receipt: receipt["source"].update(patch_sha256="b" * 64), + lambda receipt: receipt.update(interval_id_sha256="b" * 64), + lambda receipt: receipt.update(extra_metadata=True), + ): + receipt = json.loads(self.plan.receipt_json) + mutate(receipt) + forged = replace(self.plan, receipt_json=json.dumps(receipt, sort_keys=True, separators=(",", ":"))) + with self.assertRaises(IntervalReceiptBlocked): + self.persist(plan=forged) + self.assertEqual(self.store.read_calls, 0) + for invalid in ("[]", "{", "null", "NaN"): + with self.assertRaises(IntervalReceiptBlocked): + self.persist(plan=replace(self.plan, patch_json=invalid)) + self.assertEqual(self.store.read_calls, 0) + + +if __name__ == "__main__": + unittest.main() From 38e503393d280b1b6d9d5726683fef50b33fc9cf Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:10:08 -0700 Subject: [PATCH 22/47] feat: add inactive retained interval reader prerequisite --- .../interval_receipt_reader_candidate.py | 179 ++++++++++ docs/interval_receipt_reader_candidate.md | 137 ++++++++ .../test_interval_receipt_reader_candidate.py | 331 ++++++++++++++++++ 3 files changed, 647 insertions(+) create mode 100644 application/interval_receipt_reader_candidate.py create mode 100644 docs/interval_receipt_reader_candidate.md create mode 100644 tests/test_interval_receipt_reader_candidate.py diff --git a/application/interval_receipt_reader_candidate.py b/application/interval_receipt_reader_candidate.py new file mode 100644 index 00000000..11dbe53c --- /dev/null +++ b/application/interval_receipt_reader_candidate.py @@ -0,0 +1,179 @@ +"""LOCAL prerequisite: validate supplied receipts and prepare an in-memory preview. + +No backend enumeration, store call, delivery, ACK or cursor advancement exists. +Contiguity describes only the bounded supplied batch, not archive completeness. +The producer remains the single payload/identity validation authority. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field + +from application.interval_source_receipt_candidate import ( + IntervalReceiptBlocked, + IntervalReceiptConflict, + _FIELDS, + _SCHEMA, + _digest, + _json, + _payload_material, + _time, + _validate_path, +) + + +@dataclass(frozen=True) +class IntervalReceiptPreview: + """Detached output for inspection/calculation, never trusted delivery evidence.""" + + _records_json: tuple[str, ...] = field(repr=False) + interval_ids_sha256: tuple[str, ...] = field(repr=False) + payload_sha256s: tuple[str, ...] = field(repr=False) + input_count: int + duplicate_count: int + encoded_bytes: int + + @property + def delivery_status(self): + return "blocked_archive_enumeration_and_durable_ack_unqualified" + + @property + def delivery_acknowledged(self): + return False + + def live_run_records(self): + """Return fresh copies in the existing consumer envelope; never persist.""" + return [json.loads(record) for record in self._records_json] + + +def _read_receipt(entry, *, ledger_path, account_scope_sha256): + if (type(entry) not in (tuple, list) or len(entry) != 2 + or not isinstance(entry[0], str) or type(entry[1]) is not dict): + raise IntervalReceiptBlocked("interval_reader_receipt_invalid") + path, receipt = entry + if (len(receipt) != 5 + or set(receipt) != {"schema_version", "interval_id_sha256", "payload_sha256", "source", "interval"} + or receipt["schema_version"] != _SCHEMA + or type(receipt["source"]) is not dict or type(receipt["interval"]) is not dict): + raise IntervalReceiptBlocked("interval_reader_receipt_invalid") + # Resource checks precede the producer's deepcopy/Decimal work. These use + # its exact fields and string bound; they do not define another payload. + interval = receipt["interval"] + if (len(interval) != len(_FIELDS) or set(interval) != _FIELDS + or any(type(value) is not str or len(value) > 80 for value in interval.values())): + raise IntervalReceiptBlocked("interval_reader_receipt_invalid") + payload, start, end, logical_id = _payload_material(receipt["interval"]) + if (receipt["interval_id_sha256"] != logical_id + or receipt["payload_sha256"] != _digest(payload)): + raise IntervalReceiptBlocked("interval_reader_receipt_invalid") + source = receipt["source"] + if len(source) != 5 or set(source) != {"ledger_path", "ledger_before_version", "ledger_before_sha256", + "ledger_after_sha256", "patch_sha256"}: + raise IntervalReceiptBlocked("interval_reader_receipt_invalid") + if (source["ledger_path"] != ledger_path or payload["account_scope_sha256"] != account_scope_sha256 + or path != ledger_path + "__interval_receipt_" + logical_id): + raise IntervalReceiptBlocked("interval_reader_binding_mismatch") + version = source["ledger_before_version"] + if (not isinstance(version, str) + or not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{9}Z", version) + or any(not isinstance(source[key], str) or not re.fullmatch(r"[0-9a-f]{64}", source[key]) + for key in ("ledger_before_sha256", "ledger_after_sha256", "patch_sha256"))): + raise IntervalReceiptBlocked("interval_reader_receipt_invalid") + # Validate calendar seconds through the producer; preserve the separately + # checked nine-digit nanos verbatim for chronological source-order checking. + _time(version[:19] + "Z") + serialized = _json(receipt) + encoded_bytes = len(path.encode("utf-8")) + len(serialized.encode("utf-8")) + return payload, start, end, logical_id, version, serialized, encoded_bytes + + +def prepare_retained_interval_preview(receipts, *, ledger_path, account_scope_sha256, + cutover_start_at, cutover_end_at, strategy_profile, + lifecycle_stream_id, max_receipts, max_bytes): + """Validate a bounded batch, not an archive query, and prepare ordered records. + + Every supplied interval must lie within the explicit cutover and together + span it without a gap or overlap. Exact receipt duplicates are no-ops; the + same normalized logical ID with changed payload/source metadata conflicts. + Metadata hashes are structurally checked, not proven against source state. + Identical relative ledger paths cannot establish physical backend identity. + No supplied receipt or this preview can establish a durable consumer ACK. + """ + try: + return _prepare( + receipts, ledger_path=ledger_path, account_scope_sha256=account_scope_sha256, + cutover_start_at=cutover_start_at, cutover_end_at=cutover_end_at, + strategy_profile=strategy_profile, lifecycle_stream_id=lifecycle_stream_id, + max_receipts=max_receipts, max_bytes=max_bytes, + ) + except IntervalReceiptConflict: + raise IntervalReceiptConflict("interval_reader_existing_conflict") from None + except IntervalReceiptBlocked as error: + # Only fixed reasons created in this module leave this boundary. Never + # echo supplied financial material, labels, paths or backend exceptions. + reason = str(error) + if not re.fullmatch(r"interval_reader_[a-z_]+", reason): + reason = "interval_reader_receipt_invalid" + raise IntervalReceiptBlocked(reason) from None + except Exception: + raise IntervalReceiptBlocked("interval_reader_material_invalid") from None + + +def _prepare(receipts, *, ledger_path, account_scope_sha256, cutover_start_at, cutover_end_at, + strategy_profile, lifecycle_stream_id, max_receipts, max_bytes): + if (type(max_receipts) is not int or not 1 <= max_receipts <= 1000 + or type(max_bytes) is not int or not 1 <= max_bytes <= 1_048_576 + or type(receipts) not in (tuple, list)): + raise IntervalReceiptBlocked("interval_reader_limits_invalid") + if len(receipts) > max_receipts: + raise IntervalReceiptBlocked("interval_reader_count_limit") + _validate_path(ledger_path) + if (not isinstance(account_scope_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", account_scope_sha256) + or any(not isinstance(label, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", label) + for label in (strategy_profile, lifecycle_stream_id))): + raise IntervalReceiptBlocked("interval_reader_binding_invalid") + start, end = _time(cutover_start_at), _time(cutover_end_at) + if not start < end: + raise IntervalReceiptBlocked("interval_reader_cutover_invalid") + by_id, by_end = {}, {} + total_bytes, duplicates = 0, 0 + for entry in receipts: + material = _read_receipt(entry, ledger_path=ledger_path, account_scope_sha256=account_scope_sha256) + payload, interval_start, interval_end, logical_id, version, serialized, size = material + total_bytes += size + if total_bytes > max_bytes: + raise IntervalReceiptBlocked("interval_reader_bytes_limit") + if interval_start < start or interval_end > end: + raise IntervalReceiptBlocked("interval_reader_coverage_outside_cutover") + previous = by_id.get(logical_id) + if previous is not None: + if previous[5] != serialized: + raise IntervalReceiptConflict("interval_reader_existing_conflict") + duplicates += 1 + continue + if interval_end in by_end and by_end[interval_end] != interval_start: + raise IntervalReceiptConflict("interval_reader_existing_conflict") + by_end[interval_end] = interval_start + by_id[logical_id] = material + ordered = sorted(by_id.values(), key=lambda material: (material[1], material[2])) + cursor, last_version = start, None + for _, interval_start, interval_end, _, version, _, _ in ordered: + if interval_start < cursor: + raise IntervalReceiptBlocked("interval_reader_coverage_overlap") + if interval_start != cursor: + raise IntervalReceiptBlocked("interval_reader_coverage_gap") + if last_version is not None and version <= last_version: + raise IntervalReceiptBlocked("interval_reader_source_order_invalid") + cursor, last_version = interval_end, version + if cursor != end: + raise IntervalReceiptBlocked("interval_reader_coverage_gap") + records = tuple(_json({ + "strategy_profile": strategy_profile, "domain": "crypto", "record_kind": "execution", + "recorded_at": interval_end.isoformat(), "lifecycle_stream_id": lifecycle_stream_id, + "execution_result": {"external_cash_flow_interval": payload}, + }) for payload, _, interval_end, _, _, _, _ in ordered) + return IntervalReceiptPreview(records, tuple(item[3] for item in ordered), + tuple(_digest(item[0]) for item in ordered), + len(receipts), duplicates, total_bytes) diff --git a/docs/interval_receipt_reader_candidate.md b/docs/interval_receipt_reader_candidate.md new file mode 100644 index 00000000..b5bd1b92 --- /dev/null +++ b/docs/interval_receipt_reader_candidate.md @@ -0,0 +1,137 @@ +# Retained interval reader: LOCAL PREREQUISITE + +## Result and hard stop + +This candidate validates **supplied** retained receipts and prepares an ordered, +detached in-memory preview using the existing lifecycle record envelope. It does +not read a backend, enumerate an archive, deliver records, acknowledge them, +advance a cursor, clear receipts or register with runtime. Every preview reports +`blocked_archive_enumeration_and_durable_ack_unqualified` and +`delivery_acknowledged=False`. The default runtime remains unchanged. + +The exact missing consumer seam is a qualified, explicitly bound durable sink +that can create-or-verify the same logical interval plus exact payload digest, +detect conflicts, and perform authoritative readback without treating a local +fallback or a swallowed error as confirmation. The existing pinned contracts do +not provide that seam, so no ACK adapter is fabricated here. + +## Existing consumer contracts inspected + +The application pins QPK `8e8ec51884bf8abb0a7ca699fc2da39fad134f89`. + +- [PerformanceStore generic writes](https://github.com/QuantStrategyLab/QuantPlatformKit/blob/8e8ec51884bf8abb0a7ca699fc2da39fad134f89/src/quant_platform_kit/strategy_lifecycle/performance_store.py#L159-L171) + write local material before optional cloud material. The default local root is + temporary storage. No create-if-absent, atomic/fsynced local write, typed ACK or + immutable exact-payload readback is established. +- [Live run save/list](https://github.com/QuantStrategyLab/QuantPlatformKit/blob/8e8ec51884bf8abb0a7ca699fc2da39fad134f89/src/quant_platform_kit/strategy_lifecycle/performance_store.py#L386-L469) + use profile/stream/timestamp keys and overwrite writes. Save returns `None`; + cloud reads/lists suppress errors. A local read or apparently empty cloud list + is not evidence of durable acceptance or archive completeness. +- [PerformanceMonitor](https://github.com/QuantStrategyLab/QuantPlatformKit/blob/8e8ec51884bf8abb0a7ca699fc2da39fad134f89/src/quant_platform_kit/strategy_lifecycle/performance_monitor.py#L346-L444) + returns `ok` after the configured store returns. The best-effort helper returns + `None` on both success and failure. Neither qualifies consumer durability. +- [Existing interval calculator](https://github.com/QuantStrategyLab/QuantPlatformKit/blob/8e8ec51884bf8abb0a7ca699fc2da39fad134f89/src/quant_platform_kit/strategy_lifecycle/live_equity.py#L294-L557) + accepts the exact seven fields via `execution_result.external_cash_flow_interval`. + This reader reuses that envelope and preserves the seven-field payload verbatim. + Returns use the existing observation/end-flow convention, not exact TWR. + +## Pure prerequisite API + +`application/interval_receipt_reader_candidate.py` imports the receipt producer's +`_FIELDS`, `_SCHEMA`, `_payload_material`, `_time`, `_validate_path`, `_digest` and +`_json`. There is one payload/identity implementation and no reverse import, +backend dependency, environment lookup, provider default or client factory. + +`prepare_retained_interval_preview` takes: + +- A materialized list/tuple of `(receipt_path, receipt_dict)` pairs, without SDK + snapshots or lazy generators. The caller owns acquisition and provenance. +- An explicit relative ledger path and account scope hash; explicit cutover start + and end timestamps. There is no automatic historical discovery or filtering. +- An explicit strategy profile and lifecycle stream ID. Each is restricted to + 1–100 ASCII letters, digits, underscores or hyphens to avoid introducing key + sanitizer ambiguity. These labels do not qualify a real destination. +- Required positive `max_receipts` and `max_bytes`, with hard ceilings of 1,000 + supplied entries and 1,048,576 bytes. Bytes are the sum of each UTF-8 path and + the producer's canonical JSON receipt; duplicates count against both budgets. + +Before producer deepcopy or Decimal work, the reader checks the exact producer +field set and bounded string-only values. It then uses the producer's unchanged +numeric limits: finite string amounts of at most 80 characters, magnitude at most +`1e30`, exponent at least `-30`, and positive ending equity. Timestamp precision, +scope, USDT, valuation basis and interval length retain producer validation. + +The reader checks exact receipt/source keys, schema, logical ID, payload checksum, +expected receipt path, one ledger/scope, valid nine-digit-nanosecond source version +and the shape of source digest fields. It does **not** regenerate source state or +patch hashes: those underlying materials are absent from a retained receipt. +Hash consistency is not authenticity, immutability or a business-evidence check. + +Identical canonical receipt material deduplicates. Same normalized interval ID +with changed raw payload or source metadata conflicts. Thus timezone-equivalent +timestamps produce one logical ID but changed lexical payloads still conflict, +as they do in the producer. Multiple starts at one ending timestamp conflict. + +All supplied receipts must be inside the requested cutover. After sorting, +intervals must span its exact endpoints without gaps or overlaps, and source +versions must strictly increase at full nanosecond precision. No later component +is silently substituted for missing earlier material. Intervening unrelated +ledger writes are allowed, so adjacent whole-ledger digests are not falsely +required to be equal. Scope/path/version checks reject visible mixing; identical +relative paths from different physical databases cannot be distinguished here. + +The output contains only the existing execution envelope and validated interval, +plus producer interval IDs/payload digests and resource counts. Receipt source +metadata, ledger paths, prior balances and unrelated private ledger fields are +not copied into lifecycle records. JSON material is frozen and returned as fresh +copies; repr omits financial payloads. Errors have fixed reasons without input +material or exception chaining. Constructible preview objects are inspection +values, not trusted authority to persist or acknowledge anything. + +## Completeness, retry and adoption limits + +Contiguity proves only that the supplied batch spans the caller's requested time +range. It cannot prove that enumeration was complete, conflicting receipts were +not omitted, the cutover was correctly chosen, the source writer was activated, +or history/provider data were complete. No balance, flow, missing interval, +historical checkpoint, withdrawal support or execution authority is reconstructed. + +Reader retries are deterministic for identical input and never re-account funds. +The actual store regression exercises its existing local-before-cloud write order +with a synthetic throwing cloud hook: partial local bytes remain after failure, +and retry targets the same timestamp key. This demonstrates uncertainty; it does +not declare success, rollback, exactly-once delivery or durable ACK. No network, +cloud list/read, credential or real store factory is used in that regression. + +Future work requires separately qualified archive identity/query/pagination and +completeness, access/cost/retention bounds, explicit cutover, and the durable sink +ACK/readback/idempotency contract. Source/backend qualification and mixed-writer +runtime adoption remain separate. No receipt deletion or new queue/service is +proposed. Runtime switch, source release pin, workflows and ordinary writes are +unchanged; no Runtime dispatch or deployment is authorized by this candidate. + +## Local verification + +- 35 new synthetic reader/consumer tests and 37 existing receipt fake tests pass + with network and cloud factories denied: zero attempts +- Exact pinned QPK calculator/store source hashes are checked before execution; + actual calculator roundtrip produces synthetic 5% and 10% end-flow returns and + remains identical after duplicate input +- Coverage includes malformed material, cutover gaps/endpoints/overlaps, conflicts, + duplicates, source/path/scope mixing, canonical timestamp equivalence, full + nanosecond source ordering, count/byte/numeric bounds, detached output, privacy, + deterministic reader retry and actual store partial-local-write failure/retry +- `py_compile` and focused Ruff `0.15.9 --no-cache` pass using an existing tool. + The project lockfile specifies Ruff `0.15.20`; that exact version was not run + locally, and no new lint dependency was fetched. These are focused checks, not + a full application or native backend qualification +- Only this new reader, its new test file and this document are proposed additions; + all 182 prior files remain byte-for-byte unchanged + +With project dependencies installed: + +`python3 -m unittest discover -s tests -p test_interval_receipt_reader_candidate.py -v` + +The local verification runner uses the retained exact-pin consumer/calculator +sources and installed pandas/numpy. Package-path wiring and denied cloud factories +are test setup, not production installation or provider qualification. diff --git a/tests/test_interval_receipt_reader_candidate.py b/tests/test_interval_receipt_reader_candidate.py new file mode 100644 index 00000000..5649fd00 --- /dev/null +++ b/tests/test_interval_receipt_reader_candidate.py @@ -0,0 +1,331 @@ +"""Bounded synthetic receipts only; no archive, broker or delivery operations.""" + +import copy +import json +import socket +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import patch + +from application.interval_source_receipt_candidate import ( + IntervalReceiptBlocked, + IntervalReceiptConflict, + build_interval_receipt_plan, +) +from application.interval_receipt_reader_candidate import prepare_retained_interval_preview + + +LEDGER = "strategy/SYNTHETIC_STATE" +SCOPE = "a" * 64 +TIMES = [f"2026-10-0{day}T00:00:00+00:00" for day in range(1, 5)] + + +def source_receipt(index=0, *, start=None, end=None, equity="1000", flow="0", + scope=SCOPE, ledger=LEDGER, version=None): + start, end = start or TIMES[index], end or TIMES[index + 1] + previous = { + "earn_accrual_checkpoint": { + "account_scope_sha256": scope, "observed_at": start, + "assets": {"USDT": {"quantity": "1000"}}, + "execution_authority_granted": False, + }, + "external_cash_flow_cursor": {"observed_at": start, "records": []}, + "earn_accounted_net_changes": {"USDT": "0"}, + "last_balance_snapshot": {"USDT": 1000.0}, + "daily_external_principal_usdt": 0.0, + "order_submission": {"state": "TERMINAL"}, + "synthetic_private_field": "not_in_preview", + } + following = copy.deepcopy(previous) + following["earn_accrual_checkpoint"]["observed_at"] = end + following["earn_accrual_checkpoint"]["assets"]["USDT"]["quantity"] = equity + following["external_cash_flow_cursor"]["observed_at"] = end + following["last_balance_snapshot"] = {"USDT": float(equity)} + following["daily_external_principal_usdt"] = float(flow) + interval = { + "account_scope_sha256": scope, "start_at": start, "end_at": end, + "end_equity_usdt": equity, "net_external_cash_flow": flow, + "currency": "USDT", "valuation_basis": "checkpoint_quantities_sampled_prices", + } + version = version or datetime(2026, 10, index + 1, tzinfo=timezone.utc) + plan = build_interval_receipt_plan( + ledger_path=ledger, source_ledger_update_time=version, + previous_state=previous, next_state=following, interval=interval, + ) + return plan.receipt_path, json.loads(plan.receipt_json) + + +class RetainedIntervalPreviewTests(unittest.TestCase): + def setUp(self): + self.receipts = [source_receipt(0), source_receipt(1, equity="1150", flow="100"), + source_receipt(2, equity="1265")] + + def prepare(self, receipts=None, **changes): + args = { + "ledger_path": LEDGER, "account_scope_sha256": SCOPE, + "cutover_start_at": TIMES[0], "cutover_end_at": TIMES[3], + "strategy_profile": "crypto_synthetic", "lifecycle_stream_id": "synthetic_receipts", + "max_receipts": 10, "max_bytes": 100_000, + } + args.update(changes) + return prepare_retained_interval_preview(self.receipts if receipts is None else receipts, **args) + + def test_out_of_order_receipts_prepare_ordered_existing_envelope(self): + preview = self.prepare(list(reversed(self.receipts))) + records = preview.live_run_records() + self.assertEqual([r["recorded_at"] for r in records], + ["2026-10-02T00:00:00+00:00", "2026-10-03T00:00:00+00:00", + "2026-10-04T00:00:00+00:00"]) + for record, (_, receipt) in zip(records, self.receipts): + self.assertEqual(record["execution_result"], {"external_cash_flow_interval": receipt["interval"]}) + self.assertEqual(record["strategy_profile"], "crypto_synthetic") + self.assertEqual(record["domain"], "crypto") + self.assertEqual(record["record_kind"], "execution") + self.assertEqual(record["lifecycle_stream_id"], "synthetic_receipts") + + def test_native_archive_and_ack_are_explicitly_blocked(self): + preview = self.prepare() + self.assertEqual(preview.delivery_status, "blocked_archive_enumeration_and_durable_ack_unqualified") + self.assertFalse(preview.delivery_acknowledged) + + def test_exact_duplicates_are_idempotent_without_counting_flow_twice(self): + preview = self.prepare([*self.receipts, copy.deepcopy(self.receipts[1])]) + self.assertEqual(len(preview.live_run_records()), 3) + self.assertEqual(preview.duplicate_count, 1) + self.assertEqual(preview.input_count, 4) + self.assertEqual(sum(float(r["execution_result"]["external_cash_flow_interval"]["net_external_cash_flow"]) + for r in preview.live_run_records()), 100) + + def test_dictionary_key_order_is_not_a_receipt_conflict(self): + duplicate = copy.deepcopy(self.receipts[1]) + duplicate[1]["interval"] = dict(reversed(list(duplicate[1]["interval"].items()))) + duplicate = (duplicate[0], dict(reversed(list(duplicate[1].items())))) + self.assertEqual(self.prepare([*self.receipts, duplicate]).duplicate_count, 1) + + def test_reader_retry_is_identical_and_cannot_be_an_ack(self): + first, retry = self.prepare(), self.prepare(copy.deepcopy(self.receipts)) + self.assertEqual(first, retry) + self.assertFalse(retry.delivery_acknowledged) + + def test_material_is_detached_and_repr_does_not_expose_financial_payload(self): + preview = self.prepare() + original = preview.live_run_records() + self.receipts[0][1]["interval"]["end_equity_usdt"] = "987654321" + changed = preview.live_run_records() + changed[0]["execution_result"].clear() + self.assertEqual(preview.live_run_records(), original) + self.assertNotIn("end_equity", repr(preview)) + self.assertNotIn(LEDGER, repr(preview)) + serialized = json.dumps(original) + self.assertNotIn("source", serialized) + self.assertNotIn("not_in_preview", serialized) + self.assertNotIn(LEDGER, serialized) + + def test_missing_middle_interval_is_blocked(self): + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_coverage_gap"): + self.prepare([self.receipts[0], self.receipts[2]]) + + def test_missing_leading_interval_is_blocked(self): + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_coverage_gap"): + self.prepare(self.receipts[1:]) + + def test_missing_trailing_interval_is_blocked(self): + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_coverage_gap"): + self.prepare(self.receipts[:-1]) + + def test_empty_batch_is_not_coverage(self): + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_coverage_gap"): + self.prepare([]) + + def test_interval_outside_explicit_cutover_is_not_silently_filtered(self): + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_coverage_outside_cutover"): + self.prepare(cutover_end_at=TIMES[2]) + + def test_overlapping_intervals_are_blocked(self): + overlap = source_receipt(1, start="2026-10-01T12:00:00Z", end="2026-10-02T12:00:00Z") + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_coverage_overlap"): + self.prepare([self.receipts[0], overlap, self.receipts[2]]) + + def test_two_different_starts_at_same_end_are_blocked(self): + alternative = source_receipt(0, start="2026-10-01T12:00:00Z") + with self.assertRaises(IntervalReceiptConflict): + self.prepare([*self.receipts, alternative]) + + def test_same_logical_interval_changed_amount_is_a_conflict(self): + changed = source_receipt(1, equity="1151", flow="100") + with self.assertRaises(IntervalReceiptConflict): + self.prepare([*self.receipts, changed]) + + def test_same_logical_interval_changed_source_metadata_is_a_conflict(self): + changed = copy.deepcopy(self.receipts[1]) + changed[1]["source"]["ledger_after_sha256"] = "b" * 64 + with self.assertRaises(IntervalReceiptConflict): + self.prepare([*self.receipts, changed]) + + def test_timezone_equivalence_dedupes_identity_but_changed_raw_payload_conflicts(self): + equivalent = source_receipt(1, start="2026-10-01T20:00:00-04:00", end="2026-10-02T20:00:00-04:00") + self.assertEqual(equivalent[0], self.receipts[1][0]) + with self.assertRaises(IntervalReceiptConflict): + self.prepare([*self.receipts, equivalent]) + + def test_equivalent_cutover_timestamps_work_without_rewriting_payload(self): + preview = self.prepare(cutover_start_at="2026-09-30T20:00:00-04:00", + cutover_end_at="2026-10-03T20:00:00-04:00") + self.assertEqual(preview.live_run_records()[0]["execution_result"]["external_cash_flow_interval"], + self.receipts[0][1]["interval"]) + + def test_cross_scope_is_blocked(self): + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_binding_mismatch"): + self.prepare([self.receipts[0], source_receipt(1, scope="b" * 64), self.receipts[2]]) + + def test_cross_ledger_is_blocked(self): + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_binding_mismatch"): + self.prepare([self.receipts[0], source_receipt(1, ledger="strategy/OTHER"), self.receipts[2]]) + + def test_source_path_mixing_and_reused_versions_are_blocked(self): + wrong = copy.deepcopy(self.receipts) + wrong[1][1]["source"]["ledger_path"] = "strategy/OTHER" + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_binding_mismatch"): + self.prepare(wrong) + wrong = copy.deepcopy(self.receipts) + wrong[1][1]["source"]["ledger_before_version"] = wrong[0][1]["source"]["ledger_before_version"] + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_source_order_invalid"): + self.prepare(wrong) + + def test_source_versions_must_progress_with_intervals(self): + wrong = copy.deepcopy(self.receipts) + wrong[2][1]["source"]["ledger_before_version"] = "2026-09-30T00:00:00.000000001Z" + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_source_order_invalid"): + self.prepare(wrong) + + def test_receipt_identity_and_payload_digest_tampering_are_blocked(self): + for key in ("interval_id_sha256", "payload_sha256"): + wrong = copy.deepcopy(self.receipts) + wrong[1][1][key] = "b" * 64 + with self.subTest(key=key), self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_receipt_invalid"): + self.prepare(wrong) + + def test_receipt_path_tampering_is_blocked(self): + wrong = copy.deepcopy(self.receipts) + wrong[1] = ("strategy/private_credential_token", wrong[1][1]) + with self.assertRaisesRegex(IntervalReceiptBlocked, "^interval_reader_binding_mismatch$"): + self.prepare(wrong) + + def test_unknown_receipt_source_or_payload_keys_are_blocked_without_echo(self): + for area in (None, "source", "interval"): + wrong = copy.deepcopy(self.receipts) + target = wrong[1][1] if area is None else wrong[1][1][area] + target["private_token"] = "secret-do-not-log" + with self.subTest(area=area), self.assertRaises(IntervalReceiptBlocked) as caught: + self.prepare(wrong) + self.assertNotIn("secret", str(caught.exception)) + self.assertIsNone(caught.exception.__cause__) + + def test_source_version_date_and_precision_are_validated(self): + for version in ("2026-02-30T00:00:00.000000001Z", "2026-10-02T00:00:00Z", + "2026-10-02T00:00:00.0000000001Z", "private-token"): + wrong = copy.deepcopy(self.receipts) + wrong[1][1]["source"]["ledger_before_version"] = version + with self.subTest(version=version), self.assertRaises(IntervalReceiptBlocked): + self.prepare(wrong) + + def test_nanosecond_source_versions_are_not_collapsed(self): + receipts = copy.deepcopy(self.receipts) + for index, (_, receipt) in enumerate(receipts): + receipt["source"]["ledger_before_version"] = f"2026-10-01T00:00:00.00000000{index + 1}Z" + self.assertEqual(len(self.prepare(receipts).live_run_records()), 3) + + def test_count_limit_counts_duplicates(self): + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_count_limit"): + self.prepare([*self.receipts, self.receipts[0]], max_receipts=3) + + def test_bytes_limit_is_exact_and_counts_paths_and_duplicates(self): + preview = self.prepare() + self.assertEqual(self.prepare(max_bytes=preview.encoded_bytes), preview) + with self.assertRaisesRegex(IntervalReceiptBlocked, "interval_reader_bytes_limit"): + self.prepare(max_bytes=preview.encoded_bytes - 1) + duplicate = self.prepare([*self.receipts, self.receipts[0]]) + self.assertGreater(duplicate.encoded_bytes, preview.encoded_bytes) + + def test_explicit_resource_limits_and_safe_labels_are_required(self): + for changes in ({"max_receipts": 1001}, {"max_receipts": True}, {"max_receipts": 0}, + {"max_bytes": 1_048_577}, {"max_bytes": True}, {"max_bytes": 0}, + {"strategy_profile": "../private"}, {"lifecycle_stream_id": ""}, + {"account_scope_sha256": "private-token"}, {"ledger_path": "../private"}, + {"cutover_start_at": TIMES[3]}, {"cutover_end_at": "2026-10-04"}): + with self.subTest(changes=changes), self.assertRaises(IntervalReceiptBlocked): + self.prepare(**changes) + + def test_non_sequence_and_malformed_entries_are_blocked(self): + for receipts in (iter(self.receipts), "secret", [None], [("path",)], [("path", {})]): + with self.subTest(kind=type(receipts).__name__), self.assertRaises(IntervalReceiptBlocked): + self.prepare(receipts) + + def test_producer_numeric_and_timestamp_bounds_are_not_weakened(self): + for value in ("NaN", "Infinity", "1e31", "1e-31", "0", "-1", "9" * 81): + wrong = copy.deepcopy(self.receipts) + wrong[1][1]["interval"]["end_equity_usdt"] = value + with self.subTest(value=value), self.assertRaises(IntervalReceiptBlocked): + self.prepare(wrong) + wrong = copy.deepcopy(self.receipts) + wrong[1][1]["interval"]["end_at"] = "2026-10-03T00:00:00.0000001Z" + with self.assertRaises(IntervalReceiptBlocked): + self.prepare(wrong) + + def test_nested_or_oversized_payload_is_rejected_before_producer_deepcopy(self): + class PrivateBomb: + def __deepcopy__(self, memo): + raise AssertionError("private-data-must-not-be-copied") + + for value in (PrivateBomb(), {"private": "secret"}, "9" * 10_000): + wrong = copy.deepcopy(self.receipts) + wrong[1][1]["interval"]["end_equity_usdt"] = value + with self.subTest(kind=type(value).__name__), self.assertRaisesRegex( + IntervalReceiptBlocked, "^interval_reader_receipt_invalid$"): + self.prepare(wrong) + + def test_prepare_has_no_network_or_client_construction(self): + with patch.object(socket, "create_connection", side_effect=AssertionError("network_forbidden")), \ + patch.object(socket.socket, "connect", side_effect=AssertionError("network_forbidden")): + self.assertEqual(len(self.prepare().live_run_records()), 3) + + def test_actual_pinned_calculator_reads_existing_envelope_with_end_flow_convention(self): + from quant_platform_kit.strategy_lifecycle.live_equity import live_run_records_to_return_series_result + records = self.prepare().live_run_records() + result = live_run_records_to_return_series_result(records, domain="crypto") + self.assertEqual((result.status, result.detail), ("ok", "interval")) + self.assertEqual(len(result.series), 2) + self.assertAlmostEqual(result.series.iloc[0], 0.05) + self.assertAlmostEqual(result.series.iloc[1], 0.10) + duplicate_preview = self.prepare([*reversed(self.receipts), self.receipts[1]]) + duplicate_result = live_run_records_to_return_series_result(duplicate_preview.live_run_records(), domain="crypto") + self.assertTrue(result.series.equals(duplicate_result.series)) + + def test_actual_store_local_before_cloud_failure_does_not_qualify_delivery_or_retry(self): + from quant_platform_kit.strategy_lifecycle.performance_store import PerformanceStore + + class FailCloudStore(PerformanceStore): + def _write_cloud_json(self, key, payload): + raise RuntimeError("synthetic_private_endpoint_failure") + + preview = self.prepare() + record = preview.live_run_records()[0] + with tempfile.TemporaryDirectory() as directory: + store = FailCloudStore(local_root=Path(directory), cloud_bucket="synthetic_no_backend") + for _ in range(2): + with self.assertRaisesRegex(RuntimeError, "synthetic_private_endpoint_failure"): + store.save_live_run_record("crypto_synthetic", "crypto", record, + stream_id="synthetic_receipts") + # Inspect partial local evidence directly; never call the store's + # optional cloud list/read API or use a local read as a cloud ACK. + local_paths = list(Path(directory).rglob("*.json")) + self.assertEqual(len(local_paths), 1) + self.assertEqual(json.loads(local_paths[0].read_text())["execution_result"], + record["execution_result"]) + self.assertFalse(preview.delivery_acknowledged) + + +if __name__ == "__main__": + unittest.main() From fe2f9ea10fb0e5636830932196e2fbe8a05c8285 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:53:13 -0700 Subject: [PATCH 23/47] Fence bound source sessions and retain verified interval receipts opt-in --- application/portfolio_service.py | 9 +- docs/interval_source_receipt_candidate.md | 155 +++- infra/state_store.py | 6 + live_services.py | 381 +++++++++- main.py | 11 +- runtime_support.py | 60 +- tests/test_cycle_service.py | 127 ++++ tests/test_live_services.py | 870 ++++++++++++++++++++-- tests/test_portfolio_service.py | 94 +++ tests/test_runtime_support.py | 139 +++- tests/test_state_service.py | 79 ++ 11 files changed, 1809 insertions(+), 122 deletions(-) diff --git a/application/portfolio_service.py b/application/portfolio_service.py index 610a76fe..fb3eb15d 100644 --- a/application/portfolio_service.py +++ b/application/portfolio_service.py @@ -84,6 +84,10 @@ def maybe_rebase_daily_state_for_balance_change( append_log_fn, translate_fn, ): + access = getattr(runtime, "bound_state_access", None) + receipt_enabled = bool(access is not None and access.receipt_enabled) + if receipt_enabled and "earn_accrual_checkpoint" not in state: + raise ExecutionIntegrityError("earn_checkpoint_required_for_receipt") if "earn_accrual_checkpoint" in state: from application.earn_accrual import prepare_forward_earn_state, _time try: @@ -156,7 +160,10 @@ def maybe_rebase_daily_state_for_balance_change( "currency": "USDT", "valuation_basis": "checkpoint_quantities_sampled_prices", } - runtime_set_trade_state_fn(runtime, report, updated, reason="earn_forward_accounting") + if receipt_enabled: + runtime_set_trade_state_fn(runtime, report, updated, reason="earn_forward_accounting", interval=interval) + else: + runtime_set_trade_state_fn(runtime, report, updated, reason="earn_forward_accounting") state.clear() state.update(updated) runtime.trade_state = state diff --git a/docs/interval_source_receipt_candidate.md b/docs/interval_source_receipt_candidate.md index ff2b6352..95527a22 100644 --- a/docs/interval_source_receipt_candidate.md +++ b/docs/interval_source_receipt_candidate.md @@ -6,8 +6,9 @@ The simpler source-retention seam is feasible in the existing Firestore-native transaction API: create one per-interval receipt and patch the five existing forward-accounting fields in the same transaction. A separate pending queue, PerformanceStore delivery ACK and queue-clear write are unnecessary for that -bounded source-retention property. This is an isolated helper/fake-test candidate, -not runtime integration, provider verification or consumer qualification. +bounded source-retention property. The candidate now integrates that helper +through one owned/versioned bound runtime port. This is a local source candidate, +not an activated runtime, provider verification or consumer qualification. Receipts are **create-once under this candidate API**, not storage-level immutable or WORM. IAM, other writers, retention/PITR and physical production identity remain @@ -94,17 +95,15 @@ coverage, corruption/conflict handling, access and cost limits, and consumer adoption tests. Neither this proposal nor existing QPK parsing proves coverage. No current recorder/monitor starts reading these candidate receipts automatically. -Future integration needs a reviewed opt-in writer/version seam in the same bound -state backend before the current checkpoint write. Existing ordinary saves remain -blind `set`: enabling this helper alone does not fence concurrent/legacy writers -or prevent a later stale ledger overwrite. Mixed-writer rollout, owner lifecycle, -and bounded native RPC timeout/attempt configuration require integration review -before adoption. Existing post-owner-release best-effort recording must not become +The bound-port candidate below closes the ordinary/submission/forward writer +seam in this source version. Other old binaries, direct native maintenance or +external writers remain outside its fence. Mixed-writer quiescence and the +actual native backend still require qualification before adoption. Existing post-owner-release best-effort recording must not become the source ACK. Backend identity, IAM, retention, storage size/cost and historical completeness remain open decisions. No queue-capacity/trading-stop policy or archival retention policy was selected. -## Local verification +## Original helper prerequisite verification - 37 dependency-free fake-store tests passed; focused Ruff and py_compile passed - Tests cover atomic rollback including second-write failure, owner/version races, @@ -122,3 +121,141 @@ No queue-capacity/trading-stop policy or archival retention policy was selected. qualification. Read-only source retrieval is separate from fake execution Run: `python3 -m unittest discover -s tests -p test_interval_source_receipt_candidate.py -v` + +## Bound runtime source candidate (local, not activated) + +`live_services.bind_trade_state_access` now returns one `BoundTradeStateAccess`, +still iterable as the existing four `load, save, claim, release` callables. It +owns the one already-selected store/client, native ledger/owner references, +claimed owner ID, detached raw ledger and exact seconds+nanos update time. The +working state is a separate copy; mutating it cannot change the source CAS token. +Normal saves merge only the configured normalizer's output onto that detached +raw source, preserving source-only future fields under the same exact CAS. No +unknown caller input bypasses the normalizer. + +All candidate normal and order/Earn submission-state writes converge on +`runtime_support._persist_runtime_state`. The native session transaction reads +actual owner and ledger before staging a write, compares the complete raw source +and full version, and verifies the intended body/owner/full version in readback. Changed bodies +require a later version; an exactly unchanged body may retain the prior version, +as documented by native 2.28 `WriteResult`. +Cached runtime owner flags are only a preliminary gate. A revoked/replaced owner +fails even when the local flag remains true. Release checks actual owner and, +when loaded, the same ledger source; it cannot release another owner or a changed +source. No setter calls `DocumentStore.set` or refreshes a stale version. + +Public native saver admission deliberately changes: `main.set_trade_state`, +`infra.state_store.save_runtime_trade_state` and `live_services.save_trade_state` +require an explicit matching `bound_access`. A bare store, different ledger, +normalizer or fabricated capability is insufficient; an unbound native saver +returns false without selecting a provider or requesting a write. The bound +session itself raises fixed sanitized failures. Explicit custom memory savers +retain their previous infra keyword contract and cannot also supply native +capability. Startup/ForbiddenWrite, replay/memory, imports, ordinary state reads, +maintenance tools and Telegram/telemetry read paths retain their separate APIs. +Arbitrary external custom writer code cannot be fenced by this Python port. + +Receipt retention is a read-only construction option, default false: +`main.build_live_runtime(retain_interval_receipts=True)` selects it explicitly +for a permitted non-dry candidate runtime. No environment variable, source pin, +workflow or deployed switch was added/changed. Dry-run or execution-disabled +builders do not bind this mutation port. Invalid flag types fail before the live +builder. With receipt retention enabled, loading a checkpointless ledger fails +closed, and ordinary saves cannot alter/remove checkpoint or cash-flow cursor. +Forward accounting alone supplies the verified unchanged seven-field interval. +The existing helper creates the receipt and applies its exact five-field patch +before working-state replacement or report interval publication. Later metadata, +daily-reset, action, submission and final saves preserve the advanced checkpoint. +No strategy-loss reader adoption or native archive enumeration is included. + +### Exact Firestore 2.28 transport seam + +Every BeginTransaction, document BatchGet, Commit and Rollback RPC uses +`retry=None`, `timeout=10`, and the existing client's RPC metadata. Owner +create uses the original native reference with those same retry/timeout bounds. +Each operation has a finite, fixed transaction/read count; there is no pagination +or unbounded retry loop. Transaction callbacks have `max_attempts=1`, but this is +not the transport retry control. + +Installed 2.28 `Transaction._begin`, `_commit` and `_rollback` do not accept retry +or timeout controls. The decorator calls them with default GAPIC retries. +Inherited `WriteBatch.commit(retry=None, timeout=10)` also omits the native +transaction ID, so it is not used. The port calls the exact existing bound +client's GAPIC Begin/Commit/Rollback directly, retaining the original native +transaction ID, write protos and clean-up semantics. Native-shaped reference +views inject the bounds into the existing helper's `get` calls; their `parent` +returns another bounded view for receipt reads. Native 2.28 create/update use +`_document_path` by duck typing; reads delegate to the original reference with +the original native transaction. No `transaction.get(view)` is used. + +Loads and all readbacks begin actual read-only transactions. A fresh two-call +runner per receipt invocation performs read-write staging then read-only +receipt/poststate readback, rejecting reuse; the port additionally reads the +verified ledger version for its next operation. Read-only/empty transactions +close via one bounded Rollback. Cleanup rollback after a failed Commit never +proves rollback, and is attempted at most once. There is no automatic native +Commit, callback, reload or receipt retry after uncertainty. + +Public claim/load/save/release admission and completion are serialized by one +short-held per-session lock. The lock is never held across an RPC. A concurrent +or reentrant public call fails busy immediately and terminally invalidates the +session; it cannot refresh another operation's detached source. `active` is false +while an operation is in flight. External `invalidate()` latches immediately, +and the latch is never reset after construction. Checks before Begin/Commit, +after RPC completion and atomic source/owner publication prevent an in-flight +operation from rearming or reporting success after mid-RPC invalidation. A Commit +already in flight may still take effect; it is not cancelled or rolled back by +local invalidation. Normal operation completion and release closure clear busy +atomically under that same lock, without an invalidation/rearm window. + +A failed operation invalidates the source session. Native persistence uncertainty +also clears the runtime's local owner-held flag, so subsequent broker mutation +calls cannot continue even if the caller catches the initial error. A lost +response may leave an atomic receipt/poststate or submission marker committed; +the in-memory checkpoint/report is not advanced and the owner is not released. +Recovery requires a separately qualified decision/readback. The existing pure +helper's explicit original-plan replay contract remains unchanged; it is not +silently exposed as a runtime continuation after uncertainty. + +### Source consumers and rollout limits + +The actual application pin remains `8cb56617115fa45028e34d788e71884b6a303d77`. +The verified workflow selector is `66d705fd756f5648f603bfd745235b5ef389f668`; +its application/startup/migration/recovery commands check out the selected +application release before running. The account-facts reader independently uses +`ab7daec02c3bf2e58ee5fa363c26c2124f7e497f`. Both selector/reader refs are configured +at repository scope; name-only environment lookup confirms no `binance-runtime` +overrides for these two refs. This is source readback, not runtime qualification. + +CI, shadow/replay, heartbeat/target/watchdog and other manual workflows may use +their event/default-branch checkout rather than the application pin. In-repo +shadow/replay uses explicit memory writers, monitors do not call the native +ledger setter, PAPER preview imports only Telegram, and maintenance scripts use +separate owner-absent native transactions. The complete existing compatibility +suites are required for this deliberate public saver API change. External users +of the old native setter must migrate to explicit bound-session admission. + +At real cutover, establish source/database/ledger/account/stream identity and +coverage start, quiesce every old binary and maintenance/external writer, and +qualify native identity/rights, retention/PITR, read completeness and costs. +Python fencing cannot revoke another actor's direct Firestore rights. The +maintenance timestamp precision contract remains separately unqualified; do not +claim it is equivalent to this full-nanosecond runtime CAS. Receipt creation off +ends canonical coverage; it cannot synthesize history or undo the checkpoint. +Rolling back to the old unfenced binary is not a qualified source rollback. +A retained source receipt can feed the existing calculator directly; a second +PerformanceStore sink/ACK is unnecessary. No backend, broker, Runtime dispatch, +deployment or credentials were exercised to establish these source properties. + +### Verification distinction + +The candidate tests exercise the real installed Firestore 2.28 reference, +transaction, protobuf serialization and SDK timestamp objects using an anonymous +synthetic client with all RPCs stubbed, credential lookup and network forbidden. +That establishes local SDK compatibility and request bounds, not native backend +durability, IAM, server transaction semantics or production identity. Independent +native-shaped offline fakes exercise atomicity/races/uncertainty, and full-cycle +synthetic tests establish forward-before-reset/submission ordering. Full suite, +Ruff, compile, exact dependency source provenance and the final frozen write-set +checks must pass before review/publication. The actual runtime is not changed by +this isolated candidate. diff --git a/infra/state_store.py b/infra/state_store.py index fd19b785..5520c815 100644 --- a/infra/state_store.py +++ b/infra/state_store.py @@ -34,9 +34,15 @@ def save_runtime_trade_state( *, normalize_fn, saver_fn=live_save_trade_state, + bound_access=None, collection=DEFAULT_STATE_COLLECTION, document=DEFAULT_STATE_DOCUMENT, ): + if saver_fn is live_save_trade_state: + return live_save_trade_state(data, normalize_fn=normalize_fn, collection=collection, + document=document, bound_access=bound_access) + if bound_access is not None: + raise ValueError("state_session_custom_saver_mismatch") return saver_fn( data, normalize_fn=normalize_fn, diff --git a/live_services.py b/live_services.py index 0c3264d0..29f1ca86 100644 --- a/live_services.py +++ b/live_services.py @@ -1,4 +1,13 @@ +import copy import hashlib +from contextlib import contextmanager +from threading import Lock +from types import SimpleNamespace + +from application.interval_source_receipt_candidate import ( + _PATCH_FIELDS, _digest, _json, _same_backend, _version, + build_interval_receipt_plan, persist_interval_source_receipt, +) import requests @@ -37,58 +46,352 @@ def load_trade_state(*, normalize_fn, default_state_factory, normalize=True, col return None -def save_trade_state(data, *, normalize_fn, collection="strategy", document="MULTI_ASSET_STATE", store=None): - try: - persisted_state = normalize_fn(data) - (store if store is not None else _get_document_store()).set(collection=collection, document_id=document, data=persisted_state) - return True - except Exception: - print(t("firestore_write_failed", error="state_persistence_failed")) - return False +class StateSessionBlocked(RuntimeError): + """Fixed local admission/CAS failure; the session may not continue.""" -def bind_trade_state_access(*, normalize_fn, default_state_factory, - collection="strategy", document="MULTI_ASSET_STATE"): - """Bind this runtime's state and persistent owner to the same Firestore backend.""" - store = _get_document_store() +class StateSessionUncertain(RuntimeError): + """A native outcome was not established; never retry or authorize a broker.""" - def load(normalize=True): - return load_trade_state(normalize_fn=normalize_fn, default_state_factory=default_state_factory, - normalize=normalize, collection=collection, document=document, store=store) - def save(data): - return save_trade_state(data, normalize_fn=normalize_fn, collection=collection, document=document, store=store) +class _BoundReference: + """2.28-native reference view, adding bounds to the existing helper's gets. - def owner_document(): - return store.client.collection(collection).document(document + "__owner") + Native create/update use _document_path by duck typing. Reads delegate to the + original native DocumentReference with the original native transaction. + No client is constructed here and no transaction.get(view) is used. + """ - def claim(owner_id): - from google.api_core.exceptions import AlreadyExists - if not isinstance(owner_id, str) or not owner_id.strip(): - raise ValueError("state_owner_required") + def __init__(self, reference): + self._reference = reference + self._client = reference._client + self.path = reference.path + self._document_path = reference._document_path + + @property + def parent(self): + return SimpleNamespace(document=lambda name: _BoundReference(self._reference.parent.document(name))) + + def get(self, *, transaction, retry=None): + if retry is not None: + raise StateSessionBlocked("state_session_retry_forbidden") + _same_backend(bound_client=self._client, database=self._client._database_string, + refs=(self._reference,), transaction=transaction) + if self._reference.path != self.path or self._reference._document_path != self._document_path: + raise StateSessionBlocked("state_session_reference_changed") + return self._reference.get(transaction=transaction, retry=None, timeout=10) + + +class BoundTradeStateAccess: + """One owner and detached full-version source for all candidate native writes. + + Iterable for the existing four-callable binding contract. Native saves never + use DocumentStore.set, never refresh a stale source, and never retry a commit. + Any failed operation poisons this session, including owner/load/readback. + """ + + def __init__(self, store, *, normalize_fn, default_state_factory, collection, + document, receipt_enabled=False): + if type(receipt_enabled) is not bool: + raise ValueError("state_session_receipt_flag_invalid") + self._store = store + self._client = store.client + self._database = self._client._database_string + self._ledger = _BoundReference(self._client.collection(collection).document(document)) + self._owner = _BoundReference(self._client.collection(collection).document(document + "__owner")) + _same_backend(bound_client=self._client, database=self._database, refs=(self._ledger, self._owner)) + self._normalize = normalize_fn + self._default = default_state_factory + self._receipt_enabled = receipt_enabled + self._owner_id = "" + self._source = None + self._source_version = None + self._loaded = False + self._invalid = False # Terminal latch: never reset after construction. + self._session_lock = Lock() + self._busy = False + + def __iter__(self): + return iter((self.load, self.save, self.claim, self.release)) + + @property + def receipt_enabled(self): + return self._receipt_enabled + + @property + def active(self): + with self._session_lock: + return bool(self._owner_id) and not self._invalid and not self._busy + + def ensure_active(self): + if not self.active: + raise StateSessionBlocked("state_session_inactive") + + def invalidate(self): + # This lock is never held across an RPC, so external revocation can + # latch immediately while an operation is in flight. No operation rearms. + with self._session_lock: + self._invalid = True + + def _check_operation_locked(self): + if self._invalid or not self._busy: + raise StateSessionUncertain("state_session_invalidated") + + def _check_operation(self): + with self._session_lock: + self._check_operation_locked() + + @contextmanager + def _operation(self, *, claiming=False, closing=False): + # Atomic admission; overlap/reentrancy fails immediately and poisons the + # session rather than letting either caller continue using changing CAS. + with self._session_lock: + if self._busy: + self._invalid = True + raise StateSessionBlocked("state_session_busy") + if self._invalid or (claiming and self._owner_id): + raise StateSessionBlocked("state_session_claim_unavailable" if claiming else "state_session_inactive") + if not claiming and not self._owner_id: + raise StateSessionBlocked("state_session_inactive") + self._busy = True try: - owner_document().create({"owner_id": owner_id}, retry=None) - except AlreadyExists: - return False - return True - - def release(owner_id): - from google.cloud import firestore - if not isinstance(owner_id, str) or not owner_id.strip(): + yield + # Completion and clearing busy are atomic with external invalidate. + with self._session_lock: + self._check_operation_locked() + if closing: + self._invalid = True + self._busy = False + except BaseException: + self.invalidate() + with self._session_lock: + self._busy = False + raise + + def _publish_source(self, raw, version, *, loaded=False): + with self._session_lock: + self._check_operation_locked() + self._source, self._source_version = raw, version + if loaded: + self._loaded = True + + @staticmethod + def _valid_owner(owner_id): + if not isinstance(owner_id, str) or not owner_id or owner_id != owner_id.strip(): raise ValueError("state_owner_required") - ref = owner_document() - @firestore.transactional - def delete_owned(transaction): - snapshot = ref.get(transaction=transaction, retry=None) - if not snapshot.exists or snapshot.to_dict().get("owner_id") != owner_id: + def _run(self, callback, *, read_only=False): + # Firestore 2.28 Transaction._begin/_commit/_rollback omit RPC controls; + # inherited WriteBatch.commit sends transaction=None. Use exact GAPIC + # calls with the original native transaction ID and staged write protos. + self._check_operation() + transaction = self._client.transaction(max_attempts=1, read_only=read_only) + _same_backend(bound_client=self._client, database=self._database, + refs=(self._ledger, self._owner), transaction=transaction) + api = self._client._firestore_api + metadata = self._client._rpc_metadata + rollback_attempted = False + try: + response = api.begin_transaction( + request={"database": self._database, "options": transaction._options_protobuf(None)}, + retry=None, timeout=10, metadata=metadata, + ) + if not response.transaction: + raise StateSessionUncertain("state_session_begin_uncertain") + transaction._id = response.transaction + self._check_operation() + result = callback(transaction) + self._check_operation() + if transaction._write_pbs: + if read_only: + raise StateSessionBlocked("state_session_read_only_write") + api.commit(request={"database": self._database, "writes": transaction._write_pbs, + "transaction": transaction.id}, + retry=None, timeout=10, metadata=metadata) + transaction._clean_up() + else: + rollback_attempted = True + api.rollback(request={"database": self._database, "transaction": transaction.id}, + retry=None, timeout=10, metadata=metadata) + transaction._clean_up() + self._check_operation() + return result + except BaseException: + if transaction.id and not rollback_attempted: + try: + api.rollback(request={"database": self._database, "transaction": transaction.id}, + retry=None, timeout=10, metadata=metadata) + except Exception: + pass # Rollback cannot establish the outcome of a failed commit. + raise + finally: + transaction._clean_up() + + def _read_owned(self, transaction): + owner = self._owner.get(transaction=transaction, retry=None) + ledger = self._ledger.get(transaction=transaction, retry=None) + if not owner.exists or owner.to_dict() != {"owner_id": self._owner_id}: + raise StateSessionBlocked("state_session_owner_changed") + raw = ledger.to_dict() if ledger.exists else None + if raw is not None and not isinstance(raw, dict): + raise StateSessionBlocked("state_session_source_invalid") + version = ledger.update_time if ledger.exists else None + if ledger.exists: + _version(version) # Validate full SDK seconds+nanos without deepcopy. + _digest(raw) + return copy.deepcopy(raw), version + + def _matches_source(self, raw, version): + return ((None if version is None else _version(version)) + == (None if self._source_version is None else _version(self._source_version)) + and _json(raw) == _json(self._source)) + + def claim(self, owner_id): + from google.api_core.exceptions import AlreadyExists + self._valid_owner(owner_id) + with self._operation(claiming=True): + try: + _same_backend(bound_client=self._client, database=self._database, + refs=(self._ledger._reference, self._owner._reference)) + self._owner._reference.create({"owner_id": owner_id}, retry=None, timeout=10) + except AlreadyExists: return False - transaction.delete(ref) + except Exception: + raise StateSessionUncertain("state_session_claim_uncertain") from None + with self._session_lock: + self._check_operation_locked() + self._owner_id = owner_id return True - return delete_owned(store.client.transaction(max_attempts=1)) + def load(self, normalize=True): + with self._operation(): + if self._loaded: + raise StateSessionBlocked("state_session_reload_forbidden") + try: + raw, version = self._run(self._read_owned, read_only=True) + if self.receipt_enabled and (raw is None or not all(k in raw for k in ("earn_accrual_checkpoint", "external_cash_flow_cursor"))): + raise StateSessionBlocked("state_session_checkpoint_required") + output = self._normalize(copy.deepcopy(raw)) if raw is not None and normalize else ( + self._default() if normalize else copy.deepcopy(raw or {})) + self._publish_source(raw, version, loaded=True) + return copy.deepcopy(output) + except Exception: + raise StateSessionUncertain("state_session_load_uncertain") from None + + def _readback(self, intended): + raw, version = self._run(self._read_owned, read_only=True) + if raw is None or _json(raw) != _json(intended): + raise StateSessionUncertain("state_session_readback_mismatch") + if self._source_version is not None: + actual, previous = _version(version), _version(self._source_version) + # 2.28 WriteResult: unchanged writes may retain previous update_time. + unchanged = _json(intended) == _json(self._source) + if actual < previous or (actual == previous and not unchanged): + raise StateSessionUncertain("state_session_readback_version_invalid") + return raw, version + + def save(self, data, *, interval=None): + with self._operation(): + if not self._loaded: + raise StateSessionBlocked("state_session_source_required") + try: + normalized = copy.deepcopy(self._normalize(copy.deepcopy(data))) + if not isinstance(normalized, dict): + raise StateSessionBlocked("state_session_state_invalid") + source = self._source or {} + if interval is not None: + if not self.receipt_enabled or self._source is None: + raise StateSessionBlocked("state_session_receipt_unavailable") + # Normalizer defaults/filtered unknown fields are not an interval + # mutation. Compare working semantics, then patch detached RAW. + before = self._normalize(copy.deepcopy(source)) + if _json({k: v for k, v in before.items() if k not in _PATCH_FIELDS}) != _json( + {k: v for k, v in normalized.items() if k not in _PATCH_FIELDS}): + raise StateSessionBlocked("state_session_forward_fields_changed") + intended = {**copy.deepcopy(source), **{k: normalized[k] for k in _PATCH_FIELDS}} + plan = build_interval_receipt_plan( + ledger_path=self._ledger.path, source_ledger_update_time=self._source_version, + previous_state=source, next_state=intended, interval=interval, + ) + phase = 0 + + def receipt_runner(callback): + nonlocal phase + phase += 1 + if phase > 2: + raise StateSessionBlocked("state_session_receipt_runner_reused") + return self._run(callback, read_only=phase == 2) + + persist_interval_source_receipt( + bound_client=self._client, transaction_runner=receipt_runner, + ledger_ref=self._ledger, owner_ref=self._owner, owner_id=self._owner_id, + expected_ledger_update_time=self._source_version, plan=plan, + ) + else: + if self.receipt_enabled and any(_json(source.get(k)) != _json(normalized.get(k)) + for k in ("earn_accrual_checkpoint", "external_cash_flow_cursor")): + raise StateSessionBlocked("state_session_checkpoint_requires_receipt") + # Preserve source-only future fields under the same exact CAS. + intended = {**copy.deepcopy(source), **normalized} - return load, save, claim, release + def stage(transaction): + raw, version = self._read_owned(transaction) + if not self._matches_source(raw, version): + raise StateSessionBlocked("state_session_source_changed") + transaction.set(self._ledger, intended) + + self._run(stage) + self._publish_source(*self._readback(intended)) + return True + except StateSessionBlocked: + raise + except Exception: + raise StateSessionUncertain("state_session_write_uncertain") from None + + def release(self, owner_id): + self._valid_owner(owner_id) + with self._operation(closing=True): + if owner_id != self._owner_id: + raise StateSessionBlocked("state_session_owner_changed") + try: + def stage(transaction): + if self._loaded: + raw, version = self._read_owned(transaction) + if not self._matches_source(raw, version): + raise StateSessionBlocked("state_session_source_changed") + else: + owner = self._owner.get(transaction=transaction, retry=None) + if not owner.exists or owner.to_dict() != {"owner_id": owner_id}: + raise StateSessionBlocked("state_session_owner_changed") + transaction.delete(self._owner) + self._run(stage) + return True + except Exception: + raise StateSessionUncertain("state_session_release_uncertain") from None + + +def save_trade_state(data, *, normalize_fn, collection="strategy", document="MULTI_ASSET_STATE", + store=None, bound_access=None): + """Public native API: explicit matching bound session required, no blind set. + + store alone is intentionally insufficient. Custom memory savers use the infra + injection seam rather than masquerading as a native ownership capability. + """ + if (not isinstance(bound_access, BoundTradeStateAccess) + or bound_access._ledger.path != collection + "/" + document + or (store is not None and store is not bound_access._store) + or normalize_fn is not bound_access._normalize): + print(t("firestore_write_failed", error="state_persistence_failed")) + return False + return bound_access.save(data) + + +def bind_trade_state_access(*, normalize_fn, default_state_factory, + collection="strategy", document="MULTI_ASSET_STATE", receipt_enabled=False): + """Bind one owned/versioned session; receipt retention is explicitly opt-in.""" + return BoundTradeStateAccess(_get_document_store(), normalize_fn=normalize_fn, + default_state_factory=default_state_factory, collection=collection, + document=document, receipt_enabled=receipt_enabled) def send_tg_msg(token, chat_id, text): diff --git a/main.py b/main.py index 32dce915..a0be7c06 100644 --- a/main.py +++ b/main.py @@ -526,10 +526,11 @@ def get_trade_state(normalize=True): ) -def set_trade_state(data): +def set_trade_state(data, *, bound_access=None): return infra_save_runtime_trade_state( data, normalize_fn=normalize_trade_state, + bound_access=bound_access, ) @@ -833,7 +834,9 @@ def get_tradable_qty(symbol, total_qty, prices, min_bnb_value): # ========================================== # 3. Core strategy # ========================================== -def build_live_runtime(now_utc=None): +def build_live_runtime(now_utc=None, *, retain_interval_receipts=False): + if type(retain_interval_receipts) is not bool: + raise ValueError("state_session_receipt_flag_invalid") runtime = rc_build_live_runtime( now_utc=now_utc, state_loader=get_trade_state, @@ -842,9 +845,11 @@ def build_live_runtime(now_utc=None): ) _activate_execution_strategy_runtime(runtime.strategy_profile, runtime_target=runtime.runtime_target) if not runtime.dry_run and runtime.standard_execution_permitted: - runtime.state_loader, runtime.state_writer, runtime.state_owner_claim, runtime.state_owner_release = bind_trade_state_access( + runtime.bound_state_access = bind_trade_state_access( normalize_fn=normalize_trade_state, default_state_factory=build_default_state, + receipt_enabled=retain_interval_receipts, ) + runtime.state_loader, runtime.state_writer, runtime.state_owner_claim, runtime.state_owner_release = runtime.bound_state_access runtime.fuel_symbol = BNB_FUEL_SYMBOL runtime.fuel_asset = BNB_FUEL_ASSET return runtime diff --git a/runtime_support.py b/runtime_support.py index 2a5a90cf..636c9156 100644 --- a/runtime_support.py +++ b/runtime_support.py @@ -310,6 +310,7 @@ class ExecutionRuntime: print_traceback: bool = True order_sequence: int = 0 trade_state: Optional[dict[str, Any]] = None + bound_state_access: Any = None state_owner_claim: Optional[Callable[[str], bool]] = None state_owner_release: Optional[Callable[[str], bool]] = None state_owner_id: str = "" @@ -546,6 +547,36 @@ def acquire_runtime_state_owner(runtime): def require_runtime_state_owner(runtime): if not getattr(runtime, "state_owner_held", False) or not getattr(runtime, "state_owner_id", ""): raise StatePersistenceError("state_owner_required") from None + access = getattr(runtime, "bound_state_access", None) + if access is not None: + try: + access.ensure_active() + except Exception: + runtime.state_owner_held = False + raise StatePersistenceError("state_session_inactive") from None + + +def _persist_runtime_state(runtime, state, *, interval=None): + """One persistence admission seam for ordinary and submission mutations.""" + access = getattr(runtime, "bound_state_access", None) + try: + if access is not None: + if runtime.state_writer != access.save: + raise StatePersistenceError("state_session_writer_mismatch") + persisted = access.save(state, interval=interval) + else: + if interval is not None: + raise StatePersistenceError("state_session_receipt_unavailable") + if runtime.state_writer is None: + raise StatePersistenceError("state_persistence_unavailable") + persisted = runtime.state_writer(state) + if persisted is not True: + raise StatePersistenceError("state_persistence_failed") + except Exception: + if access is not None: + runtime.state_owner_held = False + access.invalidate() + raise StatePersistenceError("state_persistence_failed") from None def release_runtime_state_owner(runtime): @@ -717,27 +748,31 @@ def _accounted_funds(runtime, state, reason, item): return bool(asset and asset in observed and reason in {"cycle_complete", "cash_reconciliation"} and all(state.get("last_balance_snapshot", {}).get(key) == value for key, value in observed.items())) -def runtime_set_trade_state(runtime, report, state, *, reason): +def runtime_set_trade_state(runtime, report, state, *, reason, interval=None): payload = {"reason": str(reason)} report["state_write_intents"].append(payload) if runtime.dry_run or not getattr(runtime, "standard_execution_permitted", True): record_side_effect(runtime, report, effect_type="state_write", target="firestore", payload=payload, executed=False) return require_runtime_state_owner(runtime) - if runtime.state_writer is None: - raise StatePersistenceError("state_persistence_failed") + access = getattr(runtime, "bound_state_access", None) + if access is not None and access.receipt_enabled and reason == "earn_forward_accounting" and interval is None: + access.invalidate() + runtime.state_owner_held = False + raise StatePersistenceError("state_session_interval_required") + if interval is not None and reason != "earn_forward_accounting": + if access is not None: + access.invalidate() + runtime.state_owner_held = False + raise StatePersistenceError("state_session_interval_reason_invalid") import copy before_write = copy.deepcopy(state) try: - persisted = runtime.state_writer(state) + _persist_runtime_state(runtime, state, interval=interval) except Exception: state.clear() state.update(before_write) raise StatePersistenceError("state_persistence_failed") from None - if persisted is not True: - state.clear() - state.update(before_write) - raise StatePersistenceError("state_persistence_failed") runtime.trade_state = state runtime.pending_funds = [item for item in runtime.pending_funds if not _accounted_funds(runtime, state, reason, item)] record_side_effect(runtime, report, effect_type="state_write", target="firestore", payload=payload, executed=True) @@ -865,16 +900,9 @@ def _load_order_submission_state(runtime): def _persist_order_submission_state(runtime, state, record): require_runtime_state_owner(runtime) - if runtime.state_writer is None: - raise StatePersistenceError("state_persistence_unavailable") from None updated_state = dict(state) updated_state[_ORDER_SUBMISSION_STATE_KEY] = dict(record) - try: - persisted = runtime.state_writer(updated_state) - except Exception: - raise StatePersistenceError("state_persistence_failed") from None - if persisted is not True: - raise StatePersistenceError("state_persistence_failed") from None + _persist_runtime_state(runtime, updated_state) state[_ORDER_SUBMISSION_STATE_KEY] = dict(record) runtime.trade_state = state diff --git a/tests/test_cycle_service.py b/tests/test_cycle_service.py index 0379df84..d6160d2e 100644 --- a/tests/test_cycle_service.py +++ b/tests/test_cycle_service.py @@ -1243,3 +1243,130 @@ def execute_trend_rotation(current_runtime, report, state, *_args, **_kwargs): if __name__ == "__main__": unittest.main() + + +import pytest + + +def run_bound_source_cycle(*, receipt_enabled=True, failure=None, revoke=False): + from test_portfolio_service import forward_bound_fixture, consume_forward_bound + from runtime_support import runtime_set_trade_state as fenced_write, build_execution_report as full_report + from application.portfolio_service import maybe_reset_daily_state as reset_daily + runtime, native, observation, cash = forward_bound_fixture(receipt_enabled=receipt_enabled) + # The cycle must acquire itself and reload under that newly claimed owner. + assert runtime.bound_state_access.release(runtime.state_owner_id) + runtime.state_owner_held = False + from test_live_services import session_fixture + access, native, _ = session_fixture(native.data[native.ledger], receipt_enabled=receipt_enabled) + runtime.bound_state_access = access + runtime.state_loader, runtime.state_writer, runtime.state_owner_claim, runtime.state_owner_release = access + events = [] + def load(rt, report, allow): + state = access.load(normalize=False) + rt.trade_state = state + fenced_write(rt, report, state, reason='trend_pool_metadata_refresh') + events.append('metadata') + return state, {'degraded': False}, {}, True + def forward(state, rt, report, *_args): + if revoke: + native.change(native.owner, {'owner_id': 'replacement'}) + native.failure = failure + result = consume_forward_bound(rt, observation, cash, report) + events.append('forward_verified') + return result + def reset(state, rt, report, today, total, trend): + state['last_reset_date'] = '2026-09-11' + reset_daily(state, rt, report, today, total, trend, runtime_set_trade_state_fn=fenced_write) + events.append('daily_reset') + snapshot = {a: round(float(v['quantity']), 8) for a, v in observation['assets'].items()} + report = execute_strategy_cycle(runtime, + build_execution_report=full_report, + ensure_runtime_client=lambda *args: True, + load_cycle_execution_settings=lambda: SimpleNamespace(btc_status_report_interval_hours=24, + allow_new_trend_entries_on_degraded=False), + load_cycle_state=load, append_trend_pool_source_logs=lambda *args: None, + capture_market_snapshot=lambda *args: {'u_total': 100.0, 'fuel_val': 1500.000005, + 'dynamic_usdt_buffer': 0, 'prices': {'BTCUSDT': 50000, 'BNBUSDT': 500}, + 'balances': {'BTCUSDT': 0, 'BNBUSDT': 3.00000001}, 'btc_snapshot': {}, 'trend_indicators': {}}, + top_up_bnb_fuel=lambda *args: events.append('fuel') or (100, 1500.000005, 'ready'), + compute_portfolio_allocation=lambda *args: {'total_equity': 1600.000005, 'trend_val': 0, 'dca_val': 0, + 'execution_permitted': True, 'btc_target_ratio': 0, 'dca_usdt_pool': 0, 'btc_base_order_usdt': 0}, + build_balance_snapshot=lambda *args: copy.deepcopy(snapshot), + maybe_reset_daily_state=reset, maybe_rebase_daily_state_for_balance_change=forward, + compute_daily_pnls=lambda *args: (0, 0), append_portfolio_report=lambda *args: None, + run_daily_circuit_breaker=lambda *args: False, + execute_trend_rotation=lambda *args, **kwargs: events.append('trend') or 100, + execute_btc_dca_cycle=lambda *args: events.append('dca') or 100, + manage_usdt_earn_buffer_runtime=lambda *args, **kwargs: events.append('earn'), + maybe_send_periodic_btc_status_report=lambda *args, **kwargs: None, + runtime_set_trade_state=fenced_write, append_report_error=lambda *args, **kwargs: None, + runtime_notify=lambda *args: None, translate_fn=lambda key, **kwargs: key, traceback_module=SimpleNamespace()) + return runtime, native, report, events, observation + + +@pytest.mark.parametrize('receipt_enabled', [False, True]) +def test_full_candidate_cycle_preserves_ordering_and_forward_checkpoint_through_later_saves(receipt_enabled): + with patch('application.cycle_service.try_record_platform_execution'): + runtime, native, report, events, observation = run_bound_source_cycle(receipt_enabled=receipt_enabled) + assert report['status'] == 'ok' + assert events == ['metadata', 'forward_verified', 'daily_reset', 'fuel', 'trend', 'dca', 'earn'] + reasons = [item['reason'] for item in report['state_write_intents']] + assert reasons == ['trend_pool_metadata_refresh', 'earn_forward_accounting', 'daily_reset', 'cycle_complete'] + assert native.data[native.ledger]['earn_accrual_checkpoint'] == observation + assert native.data[native.ledger]['external_cash_flow_cursor']['observed_at'] == observation['observed_at'] + assert len([key for key in native.data if '__interval_receipt_' in key]) == int(receipt_enabled) + assert native.owner not in native.data + assert not runtime.state_owner_held and not runtime.bound_state_access.active + + +@pytest.mark.parametrize('failure,revoke', [('before_commit', False), ('after_commit', False), (None, True)]) +def test_full_cycle_uncertainty_or_midcycle_revocation_stops_before_reset_and_broker_stages(failure, revoke): + with patch('application.cycle_service.try_record_platform_execution'): + runtime, native, report, events, observation = run_bound_source_cycle(failure=failure, revoke=revoke) + assert report['status'] == 'error' + assert report['diagnostics']['cycle_failure']['stage'] == 'daily_state' + assert events == ['metadata'] + assert not runtime.state_owner_held and not runtime.bound_state_access.active + assert native.owner in native.data + assert 'external_cash_flow_interval' not in report + if failure == 'after_commit': + assert native.data[native.ledger]['earn_accrual_checkpoint'] == observation + assert len([key for key in native.data if '__interval_receipt_' in key]) == 1 + else: + assert native.data[native.ledger]['earn_accrual_checkpoint'] != observation + assert not any('__interval_receipt_' in key for key in native.data) + runtime.client.order_market_buy.assert_not_called() + + +def test_production_native_mutation_inventory_has_no_blind_saver_bypass(): + import ast + from pathlib import Path + root = Path(__file__).resolve().parents[1] + live = ast.parse((root / 'live_services.py').read_text()) + public = next(n for n in live.body if isinstance(n, ast.FunctionDef) and n.name == 'save_trade_state') + calls = [ast.unparse(n.func) for n in ast.walk(public) if isinstance(n, ast.Call)] + assert '_get_document_store' not in calls + assert not any(call.endswith('.set') for call in calls) + assert 'bound_access.save' in calls + support = ast.parse((root / 'runtime_support.py').read_text()) + writer_calls = [n for n in ast.walk(support) if isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) and n.func.attr == 'state_writer'] + assert len(writer_calls) == 1 + seam = next(n for n in support.body if isinstance(n, ast.FunctionDef) and n.name == '_persist_runtime_state') + assert writer_calls[0] in list(ast.walk(seam)) + for name in ['runtime_set_trade_state', '_persist_order_submission_state']: + node = next(n for n in support.body if isinstance(n, ast.FunctionDef) and n.name == name) + assert any(isinstance(n, ast.Call) and isinstance(n.func, ast.Name) + and n.func.id == '_persist_runtime_state' for n in ast.walk(node)) + source_calls = [] + for file in root.rglob('*.py'): + if 'tests' in file.parts or 'scripts' in file.parts: + continue + tree = ast.parse(file.read_text()) + for n in ast.walk(tree): + if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) and n.func.attr in {'set', 'update', 'create', 'delete'}: + receiver = ast.unparse(n.func.value) + if 'transaction' in receiver or receiver in {'store', 'bound_access._store'}: + source_calls.append((str(file.relative_to(root)), receiver, n.func.attr)) + assert set(path for path, _, _ in source_calls) <= { + 'live_services.py', 'application/interval_source_receipt_candidate.py'} diff --git a/tests/test_live_services.py b/tests/test_live_services.py index b41d80e9..6e6bd5c6 100644 --- a/tests/test_live_services.py +++ b/tests/test_live_services.py @@ -58,65 +58,829 @@ def test_send_tg_msg_records_safe_acknowledged_receipt(): def test_bound_trade_state_access_shares_one_store_and_uses_native_create(): - from live_services import bind_trade_state_access from google.api_core.exceptions import AlreadyExists - store = Mock() - document = store.client.collection.return_value.document.return_value - with patch('live_services._get_document_store', return_value=store) as factory: - load, save, claim, _release = bind_trade_state_access(normalize_fn=lambda x: x, default_state_factory=dict) - assert claim('owner-one') is True - document.create.assert_called_once_with({'owner_id': 'owner-one'}, retry=None) - document.create.side_effect = AlreadyExists('exists') - assert claim('owner-one') is False - store.get.return_value = {'existing': True} - assert load(normalize=False) == {'existing': True} - assert save({'updated': True}) is True - assert factory.call_count == 1 + access, client, store = session_fixture({'existing': True}) + load, save, claim, _release = access + assert claim('owner-one') is True + assert client.data[client.owner] == {'owner_id': 'owner-one'} + with patch.object(access._owner._reference, 'create', side_effect=AlreadyExists('exists')): + other, _, _ = session_fixture({'existing': True}) + with patch.object(other._owner._reference, 'create', side_effect=AlreadyExists('exists')): + assert other.claim('owner-two') is False + assert load(normalize=False) == {'existing': True} + assert save({'updated': True}) is True + store.set.assert_not_called() def test_native_owner_delete_compares_owner_and_only_returns_after_commit(): - import pytest - from google.api_core.exceptions import Aborted, DeadlineExceeded, PermissionDenied - from google.cloud import firestore - from google.cloud.firestore_v1.transaction import transactional - from live_services import bind_trade_state_access - for existing in (None, 'new-owner', 'old-owner'): - for error in (None, Aborted('conflict'), DeadlineExceeded('unknown'), PermissionDenied('denied')): - store = Mock() - ref = store.client.collection.return_value.document.return_value - ref.get.return_value = Mock(exists=existing is not None) - ref.get.return_value.to_dict.return_value = {'owner_id': existing} - tx = store.client.transaction.return_value - tx._max_attempts, tx._read_only = 1, False - tx._commit.side_effect = error - with patch('live_services._get_document_store', return_value=store), patch.object(firestore, 'transactional', transactional, create=True): - _, _, _, release = bind_trade_state_access(normalize_fn=lambda x: x, default_state_factory=dict) - if error: - with pytest.raises((ValueError, DeadlineExceeded, PermissionDenied)): - release('old-owner') - tx._rollback.assert_called_once() - else: - assert release('old-owner') is (existing == 'old-owner') - ref.get.assert_called_once_with(transaction=tx, retry=None) - store.client.transaction.assert_called_once_with(max_attempts=1) - assert tx.delete.call_count == int(existing == 'old-owner') - tx._commit.assert_called_once() + for error in (None, 'before_commit', 'after_commit'): + access, client, _ = session_fixture({'value': 1}) + access.claim('old-owner') + if existing is None: + client.data.pop(client.owner) + else: + client.change(client.owner, {'owner_id': existing}) + client.failure = error + if error or existing != 'old-owner': + with pytest.raises(RuntimeError, match='state_session_'): + access.release('old-owner') + else: + assert access.release('old-owner') is True + assert client.owner not in client.data + assert not access.active + assert sum(event[0] == 'commit' for event in client.events) <= 1 def test_native_claim_does_not_treat_uncertain_or_permission_failure_as_busy(): - import pytest from google.api_core.exceptions import DeadlineExceeded, PermissionDenied - from live_services import bind_trade_state_access - store = Mock() - with patch('live_services._get_document_store', return_value=store): - _, _, claim, release = bind_trade_state_access(normalize_fn=lambda x: x, default_state_factory=dict) - for owner in ('', ' ', None): - with pytest.raises(ValueError, match='state_owner_required'): - claim(owner) - with pytest.raises(ValueError, match='state_owner_required'): - release(owner) - for error in (DeadlineExceeded('unknown'), PermissionDenied('denied')): - store.client.collection.return_value.document.return_value.create.side_effect = error - with pytest.raises(type(error)): - claim('owner') + for owner in ('', ' ', None): + access, _, _ = session_fixture({}) + with pytest.raises(ValueError, match='state_owner_required'): + access.claim(owner) + with pytest.raises(ValueError, match='state_owner_required'): + access.release(owner) + for error in (DeadlineExceeded('SENSITIVE_UNKNOWN'), PermissionDenied('SENSITIVE_DENIED')): + access, _, _ = session_fixture({}) + with patch.object(access._owner._reference, 'create', side_effect=error): + with pytest.raises(RuntimeError, match='state_session_claim_uncertain') as caught: + access.claim('owner') + assert not access.active + assert 'SENSITIVE' not in str(caught.value) + + +# Native-shaped offline RPC fixture: never constructs an SDK client or provider. +import copy +from datetime import datetime, timedelta, timezone + +import pytest + +from application.interval_source_receipt_candidate import _version +from live_services import bind_trade_state_access + + +class SessionSnapshot: + def __init__(self, data, version): + self.exists = data is not None + self.update_time = version + self.data = copy.deepcopy(data) + + def to_dict(self): + return copy.deepcopy(self.data) + + +class SessionReference: + def __init__(self, client, path): + self._client, self.path = client, path + self._document_path = client._database_string + '/documents/' + path + self.parent = SessionCollection(client, path.rsplit('/', 1)[0]) + + def get(self, *, transaction, retry, timeout): + assert retry is None and timeout == 10 + assert transaction._client is self._client and transaction.id + assert not transaction.writes + self._client.events.append(('get', self.path)) + transaction.reads[self.path] = self._client.versions.get(self.path) + return SessionSnapshot(self._client.data.get(self.path), self._client.versions.get(self.path)) + + def create(self, data, *, retry, timeout): + assert retry is None and timeout == 10 + self._client.events.append(('claim', self.path)) + if self.path in self._client.data: + from google.api_core.exceptions import AlreadyExists + raise AlreadyExists('synthetic') + self._client.change(self.path, data) + + +class SessionCollection: + def __init__(self, client, path): + self.client, self.path = client, path + + def document(self, name): + return SessionReference(self.client, self.path + '/' + name) + + +class SessionTransaction: + def __init__(self, client): + self._client, self._max_attempts = client, 1 + self._id = None + self.reads, self.writes = {}, [] + self._read_only = False + + @property + def _write_pbs(self): + return self.writes + + def _options_protobuf(self, retry_id): + assert retry_id is None + return {'read_only': self._read_only} + + def _clean_up(self): + self._id = None + self.writes = [] + + @property + def id(self): + return self._id + + def _begin(self, *, retry, timeout): + assert retry is None and timeout == 10 + self._id = b'synthetic-transaction' + self._client.events.append(('begin',)) + + def set(self, ref, data): + self.writes.append(('set', ref.path, copy.deepcopy(data))) + + def create(self, ref, data): + self.writes.append(('create', ref.path, copy.deepcopy(data))) + + def update(self, ref, data): + self.writes.append(('update', ref.path, copy.deepcopy(data))) + + def delete(self, ref): + self.writes.append(('delete', ref.path, None)) + + def _commit(self, *, retry, timeout): + assert retry is None and timeout == 10 + self._client.events.append(('commit', tuple(w[0] for w in self.writes))) + if self._client.before_commit: + callback, self._client.before_commit = self._client.before_commit, None + callback(self._client) + if any((_version(self._client.versions[path]) if path in self._client.versions else None) + != (_version(version) if version is not None else None) + for path, version in self.reads.items()): + raise RuntimeError('synthetic CAS conflict') + if self._client.failure == 'before_commit' and self.writes: + self._client.failure = None + raise RuntimeError('SENSITIVE_BEFORE_COMMIT') + candidate = copy.deepcopy(self._client.data) + for mode, path, data in self.writes: + if mode == 'create' and path in candidate: + raise RuntimeError('synthetic create conflict') + if mode == 'delete': + candidate.pop(path, None) + elif mode == 'update': + candidate[path].update(data) + else: + candidate[path] = data + self._client.data = candidate + for _, path, _ in self.writes: + self._client.tick() + self._client.versions[path] = self._client.clock + if self._client.after_commit and self.writes: + callback, self._client.after_commit = self._client.after_commit, None + callback(self._client) + if self._client.failure == 'after_commit' and self.writes: + self._client.failure = None + raise RuntimeError('SENSITIVE_LOST_RESPONSE') + self._id = None + return [] + + def _rollback(self, *, retry, timeout): + assert retry is None and timeout == 10 + self._client.events.append(('rollback',)) + self._id = None + + +class SessionAPI: + def __init__(self, client): + self.client = client + + def begin_transaction(self, *, request, retry, timeout, metadata): + from types import SimpleNamespace + assert request['database'] == self.client._database_string + assert metadata == self.client._rpc_metadata + tx = self.client.transactions[-1] + tx._begin(retry=retry, timeout=timeout) + return SimpleNamespace(transaction=tx.id) + + def commit(self, *, request, retry, timeout, metadata): + tx = self.client.transactions[-1] + assert request['database'] == self.client._database_string + assert request['transaction'] == tx.id and tx.id + assert request['writes'] is tx._write_pbs + assert metadata == self.client._rpc_metadata + assert not tx._read_only + return tx._commit(retry=retry, timeout=timeout) + + def rollback(self, *, request, retry, timeout, metadata): + tx = self.client.transactions[-1] + assert request['database'] == self.client._database_string + assert request['transaction'] == tx.id + assert metadata == self.client._rpc_metadata + return tx._rollback(retry=retry, timeout=timeout) + + +class SessionClient: + _database_string = 'projects/synthetic-project/databases/synthetic-database' + ledger = 'strategy/MULTI_ASSET_STATE' + owner = ledger + '__owner' + + def __init__(self, data): + self.clock = datetime(2026, 10, 4, tzinfo=timezone.utc) + self.data = {} if data is None else {self.ledger: copy.deepcopy(data)} + self.versions = {} if data is None else {self.ledger: self.clock} + self.events, self.transactions = [], [] + self.before_commit = self.after_commit = self.failure = None + self._firestore_api = SessionAPI(self) + self._rpc_metadata = (('synthetic', 'safe'),) + + def tick(self): + self.clock += timedelta(microseconds=1) + + def change(self, path, data): + self.tick() + self.data[path] = copy.deepcopy(data) + self.versions[path] = self.clock + + def collection(self, name): + return SessionCollection(self, name) + + def transaction(self, *, max_attempts, read_only=False): + assert max_attempts == 1 + transaction = SessionTransaction(self) + transaction._read_only = read_only + self.transactions.append(transaction) + return transaction + + +def session_fixture(data=None, *, receipt_enabled=False, normalize_fn=lambda x: x): + client = SessionClient(data) + store = Mock(client=client) + with patch('live_services._get_document_store', return_value=store) as factory: + access = bind_trade_state_access(normalize_fn=normalize_fn, default_state_factory=dict, + receipt_enabled=receipt_enabled) + assert factory.call_count == 1 + return access, client, store + + +def test_unbound_native_save_fails_without_opening_provider(): + with patch('live_services._get_document_store', side_effect=AssertionError('provider forbidden')) as factory: + assert save_trade_state({'ok': True}, normalize_fn=lambda x: x) is False + factory.assert_not_called() + + +def test_bound_session_saves_detached_versioned_source_and_preserves_unknown_fields(): + access, client, store = session_fixture({'known': {'value': 1}, 'future': 'keep'}, + normalize_fn=lambda x: {'known': x['known']}) + assert access.claim('owner-one') is True + working = access.load() + working['known']['value'] = 2 + assert access.save(working) is True + assert client.data[client.ledger] == {'known': {'value': 2}, 'future': 'keep'} + working['known']['value'] = 99 + assert client.data[client.ledger]['known']['value'] == 2 + store.set.assert_not_called() + store.get.assert_not_called() + assert access.release('owner-one') is True + assert not access.active + + +@pytest.mark.parametrize('race', ['replace_owner', 'remove_owner', 'ledger_change', 'before_commit', 'after_commit']) +def test_bound_session_failure_permanently_stops_mutation(race): + access, client, _ = session_fixture({'value': 1}) + access.claim('owner-one') + access.load() + if race == 'replace_owner': + client.change(client.owner, {'owner_id': 'other'}) + elif race == 'remove_owner': + client.data.pop(client.owner) + elif race == 'ledger_change': + client.change(client.ledger, {'value': 3}) + else: + client.failure = race + with pytest.raises(RuntimeError, match='state_session_') as error: + access.save({'value': 2}) + assert 'SENSITIVE' not in str(error.value) + assert not access.active + count = len(client.events) + with pytest.raises(RuntimeError, match='state_session_'): + access.save({'value': 4}) + assert len(client.events) == count + with pytest.raises(RuntimeError): + access.load() # No silent rebase/reload after uncertainty. + + +def test_bound_session_rejects_non_atomic_owner_race(): + access, client, _ = session_fixture({'value': 1}) + access.claim('owner-one') + access.load() + client.before_commit = lambda c: c.change(c.owner, {'owner_id': 'replacement'}) + with pytest.raises(RuntimeError): + access.save({'value': 2}) + assert client.data[client.ledger] == {'value': 1} + assert not access.active + + +class StubNativeAPI: + """Real 2.28 references/transactions/protos, with every RPC stubbed.""" + def __init__(self, client, previous): + self.client = client + self.ledger = client.document('strategy/MULTI_ASSET_STATE')._document_path + self.owner = self.ledger + '__owner' + self.data = {self.ledger: copy.deepcopy(previous)} + self.nanos = 101 + self.versions = {self.ledger: self.nanos} + self.calls = [] + self.failure = None + self.read_only_begins = [] + + def _bounds(self, name, retry, timeout, metadata): + assert retry is None and timeout == 10 + assert metadata == self.client._rpc_metadata + self.calls.append(name) + + def begin_transaction(self, *, request, retry, timeout, metadata): + from google.cloud.firestore_v1.types import BeginTransactionResponse + self._bounds('begin', retry, timeout, metadata) + assert request['database'] == self.client._database_string + options = request['options'] + self.read_only_begins.append(bool(options is not None and options._pb.HasField('read_only'))) + return BeginTransactionResponse(transaction=b'synthetic-native-transaction') + + def rollback(self, *, request, retry, timeout, metadata): + self._bounds('rollback', retry, timeout, metadata) + assert request['transaction'] == b'synthetic-native-transaction' + if self.failure == 'rollback': + raise RuntimeError('SENSITIVE_CLEANUP') + + def batch_get_documents(self, *, request, retry, timeout, metadata): + from google.cloud.firestore_v1 import _helpers + from google.cloud.firestore_v1.types import BatchGetDocumentsResponse, Document + from google.protobuf.timestamp_pb2 import Timestamp + self._bounds('get', retry, timeout, metadata) + assert request['transaction'] == b'synthetic-native-transaction' + path = request['documents'][0] + timestamp = Timestamp(seconds=1791072000, nanos=self.versions.get(path, self.nanos)) + if path not in self.data: + return iter([BatchGetDocumentsResponse(missing=path, read_time=timestamp)]) + return iter([BatchGetDocumentsResponse(found=Document(name=path, + fields=_helpers.encode_dict(self.data[path]), create_time=timestamp, update_time=timestamp), + read_time=timestamp)]) + + def commit(self, *, request, retry, timeout, metadata): + from google.api_core.exceptions import AlreadyExists + from google.cloud.firestore_v1 import _helpers + from google.cloud.firestore_v1.types import CommitResponse, WriteResult + from google.protobuf.timestamp_pb2 import Timestamp + self._bounds('commit', retry, timeout, metadata) + candidate = copy.deepcopy(self.data) + writes = request['writes'] + assert writes + # Owner create is the sole nontransactional mutation; its exists=False + # precondition still applies. All ledger/receipt writes have native ID. + if request['transaction'] is None: + assert len(writes) == 1 and writes[0].update.name == self.owner + assert writes[0].current_document.exists is False + else: + assert request['transaction'] == b'synthetic-native-transaction' + for write in writes: + if write.delete: + candidate.pop(write.delete, None) + continue + path = write.update.name + value = _helpers.decode_dict(write.update.fields, self.client) + if write._pb.HasField('current_document') and not write.current_document.exists and path in candidate: + raise AlreadyExists('synthetic create conflict') + if write.update_mask.field_paths: + assert path in candidate + candidate[path].update(value) + else: + candidate[path] = value + before = self.data + self.data = candidate + for write in writes: + path = write.delete or write.update.name + if before.get(path) != candidate.get(path): + self.nanos += 1 + self.versions[path] = self.nanos + timestamp = Timestamp(seconds=1791072000, nanos=self.nanos) + if self.failure == 'commit': + raise RuntimeError('SENSITIVE_COMMIT_LOST') + return CommitResponse(write_results=[WriteResult(update_time=timestamp) for _ in writes], + commit_time=timestamp) + + +def native_session_fixture(previous, *, receipt_enabled=False, normalize_fn=lambda x: x): + import google.auth + import socket + from google.auth.credentials import AnonymousCredentials + from google.cloud.firestore_v1.client import Client + from google.cloud.firestore_v1.services.firestore.transports.grpc import FirestoreGrpcTransport + def forbidden(*args, **kwargs): + raise AssertionError('native fixture must not use credentials or network') + with patch.object(google.auth, 'default', forbidden), patch.object(socket.socket, 'connect', forbidden), \ + patch.object(FirestoreGrpcTransport, 'create_channel', forbidden): + client = Client(project='synthetic-project', database='synthetic-database', credentials=AnonymousCredentials()) + api = StubNativeAPI(client, previous) + client._firestore_api_internal = api # No GAPIC client/channel construction. + store = Mock(client=client) + with patch('live_services._get_document_store', return_value=store): + access = bind_trade_state_access(normalize_fn=normalize_fn, default_state_factory=dict, + receipt_enabled=receipt_enabled) + return access, api + + +def test_installed_native_sdk_reference_views_and_transaction_rpc_bounds(): + import importlib.metadata + assert importlib.metadata.version('google-cloud-firestore') == '2.28.0' + access, api = native_session_fixture({'value': 1}) + assert access.claim('owner-one') is True + assert access.load() == {'value': 1} + assert access.save({'value': 2}) is True + assert access.release('owner-one') is True + assert api.data[api.ledger] == {'value': 2} + assert api.owner not in api.data + assert api.calls.count('commit') == 3 # claim, fenced state save, fenced release + + +def test_installed_sdk_nanoseconds_are_not_collapsed_for_source_cas(): + access, api = native_session_fixture({'value': 1}) + access.claim('owner-one') + access.load() + # Real SDK snapshot update_time: nanos differ but datetime equality does not. + old = access._source_version + api.versions[api.ledger] += 1 + from google.api_core.datetime_helpers import DatetimeWithNanoseconds + advanced = DatetimeWithNanoseconds.from_rfc3339('2026-10-04T00:00:00.000000102Z') + assert old == advanced and _version(old) != _version(advanced) + commits = api.calls.count('commit') + with pytest.raises(RuntimeError, match='state_session_source_changed'): + access.save({'value': 2}) + assert api.calls.count('commit') == commits + assert not access.active + + +@pytest.mark.parametrize('failure', ['commit', 'rollback']) +def test_installed_sdk_uncertainty_is_sanitized_with_no_rpc_retry(failure): + access, api = native_session_fixture({'value': 1}) + access.claim('owner-one') + access.load() + api.failure = failure + start = len(api.calls) + with pytest.raises(RuntimeError, match='state_session_') as caught: + access.save({'value': 2}) + assert 'SENSITIVE' not in str(caught.value) + assert api.calls[start:].count('commit') == 1 + assert api.calls[start:].count('rollback') == 1 + assert not access.active + + +def test_installed_sdk_receipt_create_patch_and_bounded_read_only_readback(): + from test_interval_source_receipt_candidate import material + previous, following, interval = material() + access, api = native_session_fixture(previous, receipt_enabled=True) + access.claim('owner-one') + access.load(normalize=False) + assert access.save(following, interval=interval) is True + assert api.data[api.ledger] == following + receipts = [value for path, value in api.data.items() if '__interval_receipt_' in path] + assert len(receipts) == 1 and receipts[0]['interval'] == interval + assert api.read_only_begins == [True, False, True, True] + following['last_reset_date'] = '2026-10-05' + assert access.save(following) is True + assert api.data[api.ledger]['earn_accrual_checkpoint']['observed_at'] == interval['end_at'] + + +@pytest.mark.parametrize('key', ['earn_accrual_checkpoint', 'external_cash_flow_cursor']) +@pytest.mark.parametrize('change', ['remove', 'replace']) +def test_receipt_mode_ordinary_saves_cannot_regress_forward_fields(key, change): + from test_interval_source_receipt_candidate import material + previous, _, _ = material() + access, client, _ = session_fixture(previous, receipt_enabled=True) + access.claim('owner-one') + state = access.load(normalize=False) + if change == 'remove': + state.pop(key) + else: + state[key]['observed_at'] = '2026-10-03T23:58:00+00:00' + calls = len(client.events) + with pytest.raises(RuntimeError, match='state_session_checkpoint_requires_receipt'): + access.save(state) + assert len(client.events) == calls + assert client.data[client.ledger] == previous + assert not access.active + + +def test_receipt_mode_blocks_checkpointless_source_before_any_legacy_write(): + access, client, _ = session_fixture({'legacy': True}, receipt_enabled=True) + access.claim('owner-one') + with pytest.raises(RuntimeError, match='state_session_load_uncertain'): + access.load() + assert client.data[client.ledger] == {'legacy': True} + assert not access.active + assert not any(e[0] == 'commit' for e in client.events) + + +def test_default_off_can_initialize_missing_ledger_with_fenced_create_or_set(): + access, client, _ = session_fixture() + access.claim('owner-one') + assert access.load() == {} + assert access.save({'new': True}) is True + assert client.data[client.ledger] == {'new': True} + assert not any('__interval_receipt_' in key for key in client.data) + + +def test_forward_uses_raw_source_when_normalizer_adds_defaults_or_filters_future_fields(): + from test_interval_source_receipt_candidate import material + previous, following, interval = material() + def normalize(raw): + result = copy.deepcopy(raw) + result.pop('unknown_future_field', None) + result.setdefault('normalized_default', 0) + return result + access, client, _ = session_fixture(previous, receipt_enabled=True, normalize_fn=normalize) + access.claim('owner-one') + access.load(normalize=False) + assert access.save(normalize(following), interval=interval) is True + assert client.data[client.ledger] == following + assert 'normalized_default' not in client.data[client.ledger] + working = normalize(following) + working['last_reset_date'] = '2026-10-05' + assert access.save(working) is True + assert client.data[client.ledger]['unknown_future_field'] == {'keep': True} + + +@pytest.mark.parametrize('race', ['conflicting_receipt', 'owner_after_commit', 'poststate_after_commit']) +def test_forward_conflict_or_uncertain_readback_stops_session_and_retains_atomic_result(race): + from test_interval_source_receipt_candidate import material + from application.interval_source_receipt_candidate import build_interval_receipt_plan + previous, following, interval = material() + access, client, _ = session_fixture(previous, receipt_enabled=True) + access.claim('owner-one') + access.load(normalize=False) + if race == 'conflicting_receipt': + plan = build_interval_receipt_plan(ledger_path=client.ledger, source_ledger_update_time=access._source_version, + previous_state=previous, next_state=following, interval=interval) + client.change(plan.receipt_path, {'conflicting': 'SENSITIVE_FINANCIAL_ROW'}) + elif race == 'owner_after_commit': + client.after_commit = lambda c: c.change(c.owner, {'owner_id': 'replacement'}) + else: + client.after_commit = lambda c: c.change(c.ledger, {**following, 'future_race': True}) + with pytest.raises(RuntimeError, match='state_session_') as caught: + access.save(following, interval=interval) + assert 'SENSITIVE' not in str(caught.value) + assert not access.active + receipts = [v for k, v in client.data.items() if '__interval_receipt_' in k] + assert len(receipts) == 1 + if race == 'conflicting_receipt': + assert client.data[client.ledger] == previous + else: + assert client.data[client.ledger]['earn_accrual_checkpoint'] == following['earn_accrual_checkpoint'] + assert receipts[0]['interval'] == interval + calls = len(client.events) + with pytest.raises(RuntimeError): + access.save(following, interval=interval) + assert len(client.events) == calls # No automatic retry after ambiguous outcome. + + +def test_wrong_backend_reference_is_rejected_before_reads(): + first = SessionClient({'value': 1}) + other = SessionClient({'value': 1}) + with patch.object(first, 'collection', return_value=SessionCollection(other, 'strategy')): + with patch('live_services._get_document_store', return_value=Mock(client=first)): + with pytest.raises(ValueError, match='interval_receipt_backend_mismatch'): + bind_trade_state_access(normalize_fn=lambda x: x, default_state_factory=dict) + assert not first.events and not other.events + + +def test_wrong_transaction_client_is_rejected_before_native_begin_or_document_reads(): + access, client, _ = session_fixture({'value': 1}) + access.claim('owner-one') + other = SessionClient({'value': 1}) + with patch.object(client, 'transaction', return_value=SessionTransaction(other)): + with pytest.raises(RuntimeError, match='state_session_load_uncertain'): + access.load() + assert not any(event[0] in {'begin', 'get'} for event in client.events) + assert not access.active + + +def test_port_default_repr_omits_detached_financial_source_and_owner(): + access, _, _ = session_fixture({'SENSITIVE_BALANCE': 'private'}) + access.claim('SENSITIVE_OWNER') + access.load() + assert 'SENSITIVE' not in repr(access) + + +def test_release_checks_loaded_source_and_never_deletes_owner_on_source_race(): + access, client, _ = session_fixture({'value': 1}) + access.claim('owner-one') + access.load() + client.change(client.ledger, {'value': 2}) + with pytest.raises(RuntimeError, match='state_session_release_uncertain'): + access.release('owner-one') + assert client.owner in client.data + assert not access.active + + +def test_receipt_mode_is_read_only_after_construction(): + access, _, _ = session_fixture({}) + assert access.receipt_enabled is False + with pytest.raises(AttributeError): + access.receipt_enabled = True + + +@pytest.mark.parametrize('phase', ['claim', 'load']) +def test_reference_view_revalidates_actual_native_delegate_identity(phase): + access, client, _ = session_fixture({'value': 1}) + other = SessionClient({'value': 1}) + if phase == 'load': + access.claim('owner-one') + access._owner._reference._client = other + with pytest.raises(RuntimeError, match='state_session_'): + if phase == 'claim': + access.claim('owner-one') + else: + access.load() + assert not other.events + assert not any(event[0] == 'get' for event in client.events) + assert not access.active + + +def test_native_documented_noop_write_keeps_exact_version_without_blocking_next_save(): + access, api = native_session_fixture({'value': 1}) + access.claim('owner-one') + state = access.load() + previous = _version(access._source_version) + assert access.save(state) is True + assert _version(access._source_version) == previous + assert access.active + assert access.save({'value': 2}) is True + assert _version(access._source_version) > previous + assert api.data[api.ledger] == {'value': 2} + + +def test_changed_poststate_with_unadvanced_native_version_is_uncertain(): + access, api = native_session_fixture({'value': 1}) + access.claim('owner-one') + access.load() + previous = api.versions[api.ledger] + commit = api.commit + def unchanged_version_commit(**kwargs): + result = commit(**kwargs) + api.versions[api.ledger] = previous + return result + with patch.object(api, 'commit', side_effect=unchanged_version_commit): + with pytest.raises(RuntimeError, match='state_session_'): + access.save({'value': 2}) + assert api.data[api.ledger] == {'value': 2} # No invented rollback after committed body. + assert not access.active + + +def test_native_claim_lost_response_keeps_lock_but_never_grants_local_authority(): + access, api = native_session_fixture({'value': 1}) + api.failure = 'commit' + with pytest.raises(RuntimeError, match='state_session_claim_uncertain'): + access.claim('owner-one') + assert api.data[api.owner] == {'owner_id': 'owner-one'} + assert not access.active + calls = len(api.calls) + with pytest.raises(RuntimeError, match='state_session_claim_unavailable'): + access.claim('owner-one') + assert len(api.calls) == calls + + +def test_native_sdk_actual_normalizer_and_business_forward_preserve_raw_audit_fields(): + import main + from test_forward_earn_accounting import materials, NOW + from test_portfolio_service import consume_forward_bound + from runtime_support import ExecutionRuntime, acquire_runtime_state_owner, build_execution_report, runtime_set_trade_state + previous, observation, cash = materials() + previous['daily_external_principal_usdt'] = 0.0 + previous['future_audit_field'] = {'keep': True} + access, api = native_session_fixture(previous, receipt_enabled=True, normalize_fn=main.normalize_trade_state) + runtime = ExecutionRuntime(now_utc=NOW, client=Mock(), bound_state_access=access, + state_loader=access.load, state_writer=access.save, state_owner_claim=access.claim, + state_owner_release=access.release) + assert acquire_runtime_state_owner(runtime) + runtime.trade_state = main.normalize_trade_state(access.load(normalize=False)) + runtime.earn_accrual_observation = observation + report = build_execution_report(runtime) + runtime_set_trade_state(runtime, report, runtime.trade_state, reason='trend_pool_metadata_refresh') + assert consume_forward_bound(runtime, observation, cash, report) + assert api.data[api.ledger]['future_audit_field'] == {'keep': True} + assert api.data[api.ledger]['earn_accrual_checkpoint'] == observation + receipts = [v for k, v in api.data.items() if '__interval_receipt_' in k] + assert len(receipts) == 1 and receipts[0]['interval'] == report['external_cash_flow_interval'] + + +def test_overlapping_public_admission_cannot_refresh_source_under_another_operation(): + import threading + access, client, _ = session_fixture({'value': 1}) + access.claim('owner-one') + access.load() + admitted = threading.Event() + resume = threading.Event() + errors, successes = [], [] + original_invalidate = access.invalidate + original_normalize = access._normalize + def pause_first_once(): + if threading.current_thread().name == 'source-first' and not admitted.is_set(): + admitted.set() + assert resume.wait(5), 'deterministic overlap barrier timed out' + def gated_invalidate(): + # On the original code this pauses exactly after ensure_active but + # before its check-then-invalidate admission. On serialized code the + # normalizer below pauses the first operation after atomic admission. + pause_first_once() + original_invalidate() + def gated_normalize(data): + pause_first_once() + return original_normalize(data) + access.invalidate = gated_invalidate + access._normalize = gated_normalize + def save(value): + try: + successes.append(access.save({'value': value})) + except RuntimeError as error: + errors.append(str(error)) + first = threading.Thread(target=save, args=(2,), name='source-first') + second = threading.Thread(target=save, args=(3,), name='source-second') + first.start() + try: + assert admitted.wait(5), 'first operation did not reach admission barrier' + second.start() + second.join(5) + assert not second.is_alive(), 'busy admission must fail without waiting' + finally: + resume.set() + first.join(5) + if second.ident is not None: + second.join(5) + assert not first.is_alive() and not second.is_alive() + assert successes == [] + assert len(errors) == 2 + assert any('state_session_busy' in error for error in errors) + assert client.data[client.ledger] == {'value': 1} + assert access._source == {'value': 1} + assert not access.active + + +@pytest.mark.parametrize('operation', ['claim', 'load', 'save', 'receipt', 'release']) +def test_mid_rpc_invalidation_is_irreversible_and_cannot_publish_source_or_success(operation): + from test_interval_source_receipt_candidate import material + previous, following, interval = material() + if operation == 'receipt': + access, api = native_session_fixture(previous, receipt_enabled=True) + else: + access, api = native_session_fixture({'value': 1}) + if operation != 'claim': + access.claim('owner-one') + if operation in {'save', 'receipt', 'release'}: + access.load(normalize=False) + source = copy.deepcopy(access._source) + source_version = access._source_version + method = 'rollback' if operation == 'load' else 'commit' + original = getattr(api, method) + def invalidate_during_rpc(**kwargs): + result = original(**kwargs) + access.invalidate() + return result + with patch.object(api, method, side_effect=invalidate_during_rpc): + with pytest.raises(RuntimeError, match='state_session_'): + if operation == 'claim': + access.claim('owner-one') + elif operation == 'load': + access.load() + elif operation == 'receipt': + access.save(following, interval=interval) + elif operation == 'release': + access.release('owner-one') + else: + access.save({'value': 2}) + assert not access.active + assert access._source == source + assert access._source_version is source_version + calls = len(api.calls) + with pytest.raises(RuntimeError): + access.save({'value': 3}) + assert len(api.calls) == calls + + +@pytest.mark.parametrize('nested', ['claim', 'load', 'save', 'release']) +def test_caught_reentrant_public_call_poisons_session_before_any_native_write(nested): + access, client, _ = session_fixture({'value': 1}) + access.claim('owner-one') + access.load() + errors = [] + def reentrant_normalize(data): + try: + if nested == 'claim': + access.claim('owner-two') + elif nested == 'load': + access.load() + elif nested == 'save': + access.save({'value': 3}) + else: + access.release('owner-one') + except RuntimeError as error: + errors.append(str(error)) + return data # Catching busy does not reauthorize the outer operation. + access._normalize = reentrant_normalize + calls = len(client.events) + with pytest.raises(RuntimeError, match='state_session_'): + access.save({'value': 2}) + assert errors == ['state_session_busy'] + assert len(client.events) == calls + assert client.data[client.ledger] == {'value': 1} + assert not access.active diff --git a/tests/test_portfolio_service.py b/tests/test_portfolio_service.py index 3ddc0a55..ccc215fc 100644 --- a/tests/test_portfolio_service.py +++ b/tests/test_portfolio_service.py @@ -403,3 +403,97 @@ def test_append_portfolio_report_delegates_to_reporting_helper(self): if __name__ == "__main__": unittest.main() + + +import copy +from unittest.mock import Mock + +import pytest + +from runtime_support import build_execution_report, runtime_set_trade_state, StatePersistenceError + + +def forward_bound_fixture(*, receipt_enabled=True): + from test_forward_earn_accounting import materials, NOW + from test_runtime_support import bound_owned_runtime + state, observation, cash = materials() + state.setdefault('daily_external_principal_usdt', 0.0) + runtime, native = bound_owned_runtime(state, receipt_enabled=receipt_enabled) + runtime.now_utc = NOW + runtime.earn_accrual_observation = observation + return runtime, native, observation, cash + + +def consume_forward_bound(runtime, observation, cash, report, *, writer=runtime_set_trade_state): + return maybe_rebase_daily_state_for_balance_change( + runtime.trade_state, runtime, report, 1600.000005, 0, + {a: round(float(v['quantity']), 8) for a, v in observation['assets'].items()}, [], + collect_external_cash_flows_fn=lambda *args, **kwargs: cash, + runtime_set_trade_state_fn=writer, append_log_fn=lambda *args: None, + translate_fn=lambda *args, **kwargs: '', + ) + + +def test_verified_forward_retains_exact_interval_before_mutating_working_checkpoint(): + runtime, native, observation, cash = forward_bound_fixture() + original_state = runtime.trade_state + before = copy.deepcopy(original_state) + report = build_execution_report(runtime) + def checked_writer(rt, rp, updated, **kwargs): + assert original_state == before + assert 'external_cash_flow_interval' not in rp + assert native.data[native.ledger] == before + assert set(kwargs['interval']) == {'account_scope_sha256', 'start_at', 'end_at', 'end_equity_usdt', + 'net_external_cash_flow', 'currency', 'valuation_basis'} + runtime_set_trade_state(rt, rp, updated, **kwargs) + assert native.data[native.ledger]['earn_accrual_checkpoint'] == observation + assert original_state == before + assert consume_forward_bound(runtime, observation, cash, report, writer=checked_writer) + assert runtime.trade_state is original_state + assert original_state['earn_accrual_checkpoint'] == observation + receipts = [value for key, value in native.data.items() if '__interval_receipt_' in key] + assert len(receipts) == 1 + assert receipts[0]['interval'] == report['external_cash_flow_interval'] + assert report['diagnostics']['earn_accrual'] == {'status': 'reconciled'} + + +@pytest.mark.parametrize('failure', ['before_commit', 'after_commit']) +def test_uncertain_forward_does_not_advance_working_state_report_or_broker(failure): + runtime, native, observation, cash = forward_bound_fixture() + before = copy.deepcopy(runtime.trade_state) + native.failure = failure + report = build_execution_report(runtime) + with pytest.raises(StatePersistenceError): + consume_forward_bound(runtime, observation, cash, report) + assert runtime.trade_state == before + assert 'external_cash_flow_interval' not in report + assert not runtime.bound_state_access.active + assert not runtime.state_owner_held + if failure == 'before_commit': + assert native.data[native.ledger] == before + assert not any('__interval_receipt_' in key for key in native.data) + else: + assert native.data[native.ledger]['earn_accrual_checkpoint'] == observation + assert len([key for key in native.data if '__interval_receipt_' in key]) == 1 + runtime.client.order_market_buy.assert_not_called() + + +def test_default_off_preserves_forward_ordinary_save_and_existing_payload(): + runtime, native, observation, cash = forward_bound_fixture(receipt_enabled=False) + report = build_execution_report(runtime) + assert consume_forward_bound(runtime, observation, cash, report) + assert native.data[native.ledger]['earn_accrual_checkpoint'] == observation + assert not any('__interval_receipt_' in key for key in native.data) + assert report['external_cash_flow_interval']['end_equity_usdt'] == '1600.000005' + + +def test_enabled_checkpointless_legacy_branch_does_not_read_history_or_call_saver(): + runtime = SimpleNamespace(bound_state_access=SimpleNamespace(receipt_enabled=True)) + read = Mock() + save = Mock() + with pytest.raises(ExecutionIntegrityError, match='earn_checkpoint_required_for_receipt'): + maybe_rebase_daily_state_for_balance_change({}, runtime, {}, 100, 0, {'USDT': 100}, [], + collect_external_cash_flows_fn=read, runtime_set_trade_state_fn=save, + append_log_fn=Mock(), translate_fn=Mock()) + read.assert_not_called() + save.assert_not_called() diff --git a/tests/test_runtime_support.py b/tests/test_runtime_support.py index 2d772ff7..4f9b68b0 100644 --- a/tests/test_runtime_support.py +++ b/tests/test_runtime_support.py @@ -5,9 +5,10 @@ import traceback import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import Mock, patch import requests +import pytest if not hasattr(requests, "exceptions"): class RequestException(OSError): @@ -1542,3 +1543,139 @@ def test_release_uncertainty_does_not_authorize_old_owner_and_readonly_never_cla assert acquire_runtime_state_owner(runtime) is True claim.assert_not_called() assert runtime.state_owner_held is False + + +# All native submission and ordinary writes use the same server-checked port. +def bound_owned_runtime(previous, *, receipt_enabled=False): + from test_live_services import session_fixture + access, native, _ = session_fixture(previous, receipt_enabled=receipt_enabled) + runtime = ExecutionRuntime(client=Mock(), bound_state_access=access, state_loader=access.load, + state_writer=access.save, state_owner_claim=access.claim, state_owner_release=access.release) + assert acquire_runtime_state_owner(runtime) + runtime.trade_state = access.load(normalize=False) + return runtime, native + + +@pytest.mark.parametrize('kind', ['ordinary', 'submission', 'broker_submission']) +@pytest.mark.parametrize('revocation', ['removed', 'replaced']) +def test_revoked_owner_cached_local_flag_cannot_mutate_or_submit(kind, revocation): + from runtime_support import StatePersistenceError, _persist_order_submission_state + runtime, native = bound_owned_runtime({'order_submission': {'state': 'RESERVED'}, 'value': 1}) + if revocation == 'removed': + native.data.pop(native.owner) + else: + native.change(native.owner, {'owner_id': 'replacement'}) + assert runtime.state_owner_held # Intentionally stale cached local flag. + report = build_execution_report(runtime) + with pytest.raises(StatePersistenceError): + if kind == 'ordinary': + runtime_set_trade_state(runtime, report, runtime.trade_state, reason='cycle_complete') + elif kind == 'submission': + _persist_order_submission_state(runtime, runtime.trade_state, {'state': 'TERMINAL'}) + else: + runtime_call_client(runtime, report, method_name='order_market_buy', + payload={'symbol': 'BTCUSDT', 'quantity': 1}, effect_type='order_buy') + assert native.data[native.ledger]['order_submission']['state'] == 'RESERVED' + assert not runtime.state_owner_held + assert not runtime.bound_state_access.active + runtime.client.order_market_buy.assert_not_called() + with pytest.raises(StatePersistenceError): + runtime_call_client(runtime, report, method_name='order_market_buy', + payload={'symbol': 'BTCUSDT', 'quantity': 1}, effect_type='order_buy') + runtime.client.order_market_buy.assert_not_called() + + +@pytest.mark.parametrize('failure', ['before_commit', 'after_commit']) +def test_submission_commit_uncertainty_halts_broker_and_cannot_retry(failure): + from runtime_support import StatePersistenceError + runtime, native = bound_owned_runtime({'order_submission': {'state': 'RESERVED'}}) + native.failure = failure + report = build_execution_report(runtime) + with pytest.raises(StatePersistenceError): + runtime_call_client(runtime, report, method_name='order_market_buy', + payload={'symbol': 'BTCUSDT', 'quantity': 1}, effect_type='order_buy') + runtime.client.order_market_buy.assert_not_called() + assert not runtime.state_owner_held and not runtime.bound_state_access.active + if failure == 'after_commit': + assert native.data[native.ledger]['order_submission']['state'] == 'SUBMISSION_UNKNOWN' + else: + assert native.data[native.ledger]['order_submission']['state'] == 'RESERVED' + calls = len(native.events) + with pytest.raises(StatePersistenceError): + runtime_call_client(runtime, report, method_name='order_market_buy', + payload={'symbol': 'BTCUSDT', 'quantity': 1}, effect_type='order_buy') + assert len(native.events) == calls + + +def test_bound_native_port_rejects_replaced_writer_before_any_mutation(): + from runtime_support import StatePersistenceError, _persist_order_submission_state + runtime, native = bound_owned_runtime({'order_submission': {'state': 'RESERVED'}}) + rogue = Mock(return_value=True) + runtime.state_writer = rogue + with pytest.raises(StatePersistenceError): + _persist_order_submission_state(runtime, runtime.trade_state, {'state': 'TERMINAL'}) + rogue.assert_not_called() + assert not runtime.bound_state_access.active + assert native.data[native.ledger]['order_submission']['state'] == 'RESERVED' + + +def test_receipt_forward_requires_interval_and_reason_on_bound_runtime(): + from test_interval_source_receipt_candidate import material + from runtime_support import StatePersistenceError + previous, following, interval = material() + for reason, payload in [('earn_forward_accounting', None), ('daily_reset', interval)]: + runtime, native = bound_owned_runtime(previous, receipt_enabled=True) + calls = len(native.events) + with pytest.raises(StatePersistenceError): + runtime_set_trade_state(runtime, build_execution_report(runtime), following, + reason=reason, interval=payload) + assert len(native.events) == calls + assert not runtime.bound_state_access.active + assert not runtime.state_owner_held + + +def test_ordinary_then_all_submission_transition_saves_preserve_committed_receipt_checkpoint(): + from test_interval_source_receipt_candidate import material + from runtime_support import _persist_order_submission_state + previous, following, interval = material() + runtime, native = bound_owned_runtime(previous, receipt_enabled=True) + report = build_execution_report(runtime) + state = runtime.trade_state + state['last_reset_date'] = previous['last_reset_date'] + runtime_set_trade_state(runtime, report, state, reason='trend_pool_metadata_refresh') + runtime_set_trade_state(runtime, report, following, reason='earn_forward_accounting', interval=interval) + for record in [{'state': 'RESERVED'}, {'state': 'SUBMISSION_UNKNOWN', 'identity_sha256': 'b' * 64, 'symbol': 'ETHUSDT'}, + {'state': 'FILLED_ACCOUNTING_PENDING', 'identity_sha256': 'b' * 64, 'symbol': 'ETHUSDT', + 'known_fill': {'quantity': '1', 'quote_quantity': '100', 'commissions': []}}, + {'state': 'TERMINAL'}, + {'state': 'SUBMISSION_UNKNOWN', 'identity_sha256': 'c' * 64, + 'method_name': 'subscribe_simple_earn_flexible_product', + 'funding_receipt': {'id': 1, 'asset': 'USDT', 'product_id': 'synthetic', + 'amount': '1', 'spot_before': '10'}}, {'state': 'TERMINAL'}]: + _persist_order_submission_state(runtime, following, record) + assert native.data[native.ledger]['earn_accrual_checkpoint'] == following['earn_accrual_checkpoint'] + for reason in ['daily_reset', 'daily_circuit_breaker', 'trend_buy', 'trend_sell', 'btc_dca_buy', + 'btc_dca_sell', 'cash_reconciliation', 'cycle_complete']: + runtime_set_trade_state(runtime, report, following, reason=reason) + assert native.data[native.ledger]['external_cash_flow_cursor'] == following['external_cash_flow_cursor'] + assert len([key for key in native.data if '__interval_receipt_' in key]) == 1 + + +def test_external_mid_commit_invalidation_cannot_rearm_runtime_or_continue_broker(): + from runtime_support import StatePersistenceError + runtime, native = bound_owned_runtime({'order_submission': {'state': 'RESERVED'}, 'value': 1}) + native.after_commit = lambda _native: runtime.bound_state_access.invalidate() + report = build_execution_report(runtime) + previous_source = copy.deepcopy(runtime.bound_state_access._source) + with pytest.raises(StatePersistenceError): + runtime_set_trade_state(runtime, report, {'order_submission': {'state': 'RESERVED'}, 'value': 2}, + reason='cycle_complete') + assert native.data[native.ledger]['value'] == 2 + assert runtime.bound_state_access._source == previous_source + assert not runtime.state_owner_held and not runtime.bound_state_access.active + calls = len(native.events) + with pytest.raises(StatePersistenceError): + runtime_call_client(runtime, report, method_name='order_market_buy', + payload={'symbol': 'BTCUSDT', 'quantity': 1}, effect_type='order_buy') + runtime.client.order_market_buy.assert_not_called() + assert len(native.events) == calls diff --git a/tests/test_state_service.py b/tests/test_state_service.py index 56428844..0693f613 100644 --- a/tests/test_state_service.py +++ b/tests/test_state_service.py @@ -428,3 +428,82 @@ def test_legacy_rebase_snapshot_scope_is_preserved_by_effective_universe(): balances.update({"BTCUSDT": 0.0, "BNBUSDT": 0.0}) snapshot = build_balance_snapshot(result[2], balances, 100.0) assert set(snapshot) == set(raw["last_balance_snapshot"]) + + +def test_public_and_infra_native_savers_require_exact_bound_capability_without_provider_lookup(): + from unittest.mock import patch + import main + from live_services import save_trade_state + from test_live_services import session_fixture + access, client, _ = session_fixture({'value': 1}, normalize_fn=main.normalize_trade_state) + access.claim('owner-one') + access.load(normalize=False) + calls = len(client.events) + with patch('live_services._get_document_store', side_effect=AssertionError('no provider lookup')) as factory: + assert main.set_trade_state({'value': 2}) is False + assert save_runtime_trade_state({'value': 2}, normalize_fn=main.normalize_trade_state) is False + assert save_trade_state({'value': 2}, normalize_fn=main.normalize_trade_state, + store=object(), bound_access=access) is False + assert save_trade_state({'value': 2}, normalize_fn=main.normalize_trade_state, + document='OTHER', bound_access=access) is False + assert save_trade_state({'value': 2}, normalize_fn=lambda x: x, bound_access=access) is False + factory.assert_not_called() + assert len(client.events) == calls + assert main.set_trade_state({'value': 2}, bound_access=access) is True + assert client.data[client.ledger]['value'] == 1 # Source-only unknown retained; normalizer filters new input. + + +def test_memory_saver_keeps_existing_keyword_contract_but_cannot_impersonate_bound_native_access(): + from unittest.mock import Mock + saver = Mock(return_value=True) + def normalize(value): + return value + assert save_runtime_trade_state({'value': 1}, normalize_fn=normalize, saver_fn=saver) is True + saver.assert_called_once_with({'value': 1}, normalize_fn=normalize, collection='strategy', document='MULTI_ASSET_STATE') + with pytest.raises(ValueError, match='state_session_custom_saver_mismatch'): + save_runtime_trade_state({'value': 2}, normalize_fn=normalize, saver_fn=saver, bound_access=object()) + assert saver.call_count == 1 + + +def test_live_builder_binds_one_default_off_port_only_for_permitted_non_dry_runtime(): + from unittest.mock import Mock, patch + import main + from runtime_support import ExecutionRuntime + for dry_run, permitted in [(True, True), (False, False), (False, True)]: + rt = ExecutionRuntime(dry_run=dry_run, standard_execution_permitted=permitted) + bound = Mock() + bound.__iter__ = Mock(return_value=iter((Mock(), Mock(), Mock(), Mock()))) + with patch.object(main, 'rc_build_live_runtime', return_value=rt), \ + patch.object(main, '_activate_execution_strategy_runtime'), \ + patch.object(main, 'bind_trade_state_access', return_value=bound) as bind: + assert main.build_live_runtime() is rt + if not dry_run and permitted: + bind.assert_called_once_with(normalize_fn=main.normalize_trade_state, + default_state_factory=main.build_default_state, receipt_enabled=False) + assert rt.bound_state_access is bound + else: + bind.assert_not_called() + assert rt.bound_state_access is None + + +def test_candidate_receipt_flag_is_explicit_and_not_environment_selected(): + from unittest.mock import Mock, patch + import main + from runtime_support import ExecutionRuntime + rt = ExecutionRuntime() + port = Mock() + port.__iter__ = Mock(return_value=iter((Mock(), Mock(), Mock(), Mock()))) + with patch.object(main, 'rc_build_live_runtime', return_value=rt), \ + patch.object(main, '_activate_execution_strategy_runtime'), \ + patch.object(main, 'bind_trade_state_access', return_value=port) as bind: + main.build_live_runtime(retain_interval_receipts=True) + assert bind.call_args.kwargs['receipt_enabled'] is True + + +def test_invalid_receipt_option_fails_before_live_runtime_builder_or_provider(): + from unittest.mock import patch + import main + with patch.object(main, 'rc_build_live_runtime') as builder: + with pytest.raises(ValueError, match='state_session_receipt_flag_invalid'): + main.build_live_runtime(retain_interval_receipts='true') + builder.assert_not_called() From 9e97c4abea096b9c7b62074237198f070b9fdf9f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 5 Oct 2026 04:00:18 +0800 Subject: [PATCH 24/47] Scope writer inventory to owned source instead of CI dependencies --- tests/test_cycle_service.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/tests/test_cycle_service.py b/tests/test_cycle_service.py index d6160d2e..670a2055 100644 --- a/tests/test_cycle_service.py +++ b/tests/test_cycle_service.py @@ -1358,9 +1358,21 @@ def test_production_native_mutation_inventory_has_no_blind_saver_bypass(): node = next(n for n in support.body if isinstance(n, ast.FunctionDef) and n.name == name) assert any(isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == '_persist_runtime_state' for n in ast.walk(node)) + assert set(path for path, _, _ in _owned_native_mutation_calls(root)) <= { + 'live_services.py', 'application/interval_source_receipt_candidate.py'} + + +def _owned_native_mutation_calls(root): + import ast source_calls = [] - for file in root.rglob('*.py'): - if 'tests' in file.parts or 'scripts' in file.parts: + # These are this repository's verified production roots. CI's external/ + # checkouts and .venv/ packages are separate dependencies, not owned writers. + files = list(root.glob('*.py')) + for directory in ('application', 'entrypoints', 'infra', 'reporting', 'research', 'strategy'): + files.extend((root / directory).rglob('*.py')) + caches = {'__pycache__', '.pytest_cache', '.ruff_cache', '.mypy_cache', '.cache', '.venv'} + for file in sorted(files): + if any(part in caches for part in file.relative_to(root).parts[:-1]): continue tree = ast.parse(file.read_text()) for n in ast.walk(tree): @@ -1368,5 +1380,16 @@ def test_production_native_mutation_inventory_has_no_blind_saver_bypass(): receiver = ast.unparse(n.func.value) if 'transaction' in receiver or receiver in {'store', 'bound_access._store'}: source_calls.append((str(file.relative_to(root)), receiver, n.func.attr)) - assert set(path for path, _, _ in source_calls) <= { - 'live_services.py', 'application/interval_source_receipt_candidate.py'} + return source_calls + + +def test_owned_writer_inventory_excludes_ci_dependencies_but_detects_nested_owned_writer(tmp_path): + owned = tmp_path / 'application' / 'nested' / 'writer.py' + owned.parent.mkdir(parents=True) + owned.write_text('store.set(data)\n') + for relative in ['external/QuantPlatformKit/provider.py', '.venv/lib/site-packages/provider.py', + 'application/__pycache__/cache.py', '.pytest_cache/cache.py', '.ruff_cache/cache.py']: + path = tmp_path / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('transaction.update(ref, data)\n') + assert _owned_native_mutation_calls(tmp_path) == [('application/nested/writer.py', 'store', 'set')] From 329ed5a4638cc4c540f34810822a310607d663c2 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:48:00 +0800 Subject: [PATCH 25/47] docs: qualify the interval receipt reader candidate Co-Authored-By: Codex --- docs/interval_receipt_reader_candidate.md | 141 ++++++++++++++++++++-- 1 file changed, 130 insertions(+), 11 deletions(-) diff --git a/docs/interval_receipt_reader_candidate.md b/docs/interval_receipt_reader_candidate.md index b5bd1b92..619fdcd6 100644 --- a/docs/interval_receipt_reader_candidate.md +++ b/docs/interval_receipt_reader_candidate.md @@ -9,11 +9,14 @@ advance a cursor, clear receipts or register with runtime. Every preview reports `blocked_archive_enumeration_and_durable_ack_unqualified` and `delivery_acknowledged=False`. The default runtime remains unchanged. -The exact missing consumer seam is a qualified, explicitly bound durable sink -that can create-or-verify the same logical interval plus exact payload digest, -detect conflicts, and perform authoritative readback without treating a local -fallback or a swallowed error as confirmation. The existing pinned contracts do -not provide that seam, so no ACK adapter is fabricated here. +The missing native seam is qualified acquisition of the existing retained source +archive, with explicit physical identity, complete bounded enumeration, cutover +and retention evidence. As the later +[bound source contract](interval_source_receipt_candidate.md#source-consumers-and-rollout-limits) +states, retained receipts can feed the existing calculator directly. A second +PerformanceStore sink or delivery ACK is unnecessary for that read-only path. +The preview's existing delivery-status string is retained for compatibility; it +is not a requirement to create another sink. No ACK adapter is fabricated here. ## Existing consumer contracts inspected @@ -104,13 +107,129 @@ not declare success, rollback, exactly-once delivery or durable ACK. No network, cloud list/read, credential or real store factory is used in that regression. Future work requires separately qualified archive identity/query/pagination and -completeness, access/cost/retention bounds, explicit cutover, and the durable sink -ACK/readback/idempotency contract. Source/backend qualification and mixed-writer -runtime adoption remain separate. No receipt deletion or new queue/service is -proposed. Runtime switch, source release pin, workflows and ordinary writes are -unchanged; no Runtime dispatch or deployment is authorized by this candidate. +completeness, access/cost/retention bounds and explicit cutover. Source/backend +qualification and mixed-writer runtime adoption remain separate. Direct +calculation does not require a sink ACK; adding a sink would require its own +ACK/readback/idempotency qualification. No receipt deletion or new queue/service +is proposed. Runtime switch, source release pin, workflows and ordinary writes +are unchanged; no Runtime dispatch or deployment is authorized by this candidate. -## Local verification +## Native input preflight and existing handoff + +The 2026-10-05 offline preflight is against Binance main +`db87064bbd89a67d021694e02bcdbc337c164180` and its unchanged QPK pin +`8e8ec51884bf8abb0a7ca699fc2da39fad134f89`. The existing reader already separates +supplied-batch integrity from native authenticity and completeness. No additional +preflight API, receipt format, archive service or production query is needed to +represent this distinction. + +Available evidence establishes the source/reader code and synthetic regressions. +It does not establish a native archive, physical database or authorized protected +read entry, actual account scope, retained window, cutover, retention/PITR policy, +terminal-page enumeration or current runtime adoption. Missing evidence means +unqualified, not proof that an archive does not exist. The default-off +construction option does not establish the flag of a running process. + +The following is an evidence handoff checklist, not a new accepted schema or +authorization to acquire protected records: + +1. Source and permitted entry: original retained-export location or approved + read entry, provenance/checksum, observation time, read identity and permitted + fields/window; exact producing application revision and backend/SDK version. + Do not construct another client or infer a cloud principal from this document. +2. Physical binding: full `projects/{project}/databases/{database}` identity, + original relative ledger path, native account-scope digest and its original + account binding; chosen strategy profile and lifecycle stream. The source + defaults `strategy/MULTI_ASSET_STATE` and sibling document pattern + `__interval_receipt_` are code conventions, not a + verified production entry. Identical relative paths do not bind a database. +3. Window and cutover: exact requested receipt start/end, producing revision, + first retained checkpoint and full source version, retention-enable and + disable/gap boundaries, and old/maintenance/external-writer quiescence evidence. + Rebase dates, current balances or a first observed receipt cannot create an + earlier retained start or opening equity. +4. Enumeration: original query/filter/ordering definition, one authoritative + as-of/read snapshot across all pages, each requested/returned cursor, page + count and bytes, document identities, terminal marker and all errors or + truncations. Complete source enumeration must precede selection of the + bounded reader batch. Hash-suffixed document IDs are not chronological, and + raw interval time strings can use equivalent UTC offsets; neither lexical + timestamp filtering nor contiguity proves complete enumeration. No native + query/index or snapshot-pagination contract is qualified by this preflight. +5. Retention and access: actual receipt retention/PITR and deletion history for + the window, read completeness and limits, permitted principal, indexes and + storage/read-cost bounds. Create-once API behavior is not WORM or IAM evidence. +6. Business source: original checkpoint/flow/fill/fee/price provenance sufficient + for the supported Spot plus Flexible Earn scope; USDT external deposits must + remain distinct from same-account movements. The seven-field receipt alone + cannot prove these gates. Unsupported withdrawals, unknown fees, FX or scope + changes remain unavailable; do not substitute wallet estimates or daily totals. + +For each supplied receipt, preserve exactly these existing fields: + +- `schema_version`, `interval_id_sha256`, `payload_sha256`, `source`, `interval` +- Source: `ledger_path`, `ledger_before_version` (nine-digit UTC nanoseconds), + `ledger_before_sha256`, `ledger_after_sha256`, `patch_sha256` +- Interval: `account_scope_sha256`, `start_at`, `end_at`, `end_equity_usdt`, + `net_external_cash_flow`, `currency`, `valuation_basis` + +Keep original receipt paths, unchanged field values and export-byte checksums +with external provenance. Do not +change lexical timestamps, regenerate hashes, fabricate ledgers or repair missing +pages to make an export pass. Redaction that changes covered receipt material +cannot be presented as the original native payload. A synthetic fixture, a +redacted inspection copy and an unchanged native export are different inputs; +successful parsing does not promote any of them to verified native evidence. + +Once original supplied material is independently qualified, the existing local +calculation path is: + +1. Materialize bounded `(original_receipt_path, original_receipt_dict)` pairs. +2. Call `prepare_retained_interval_preview` with the separately verified ledger, + account-scope digest, exact receipt-window endpoints, explicit profile/stream + and count/byte budgets. Hard limits stay 1,000 entries and 1,048,576 bytes, + counting duplicates. Exceeding them blocks this batch; do not silently truncate + or concatenate independent batches to claim a whole-window pass. +3. Pass `preview.live_run_records()` directly to the existing pinned + `live_run_records_to_return_series_result(..., domain="crypto")`. No store + write, delivery ACK, receipt deletion or cursor change is part of this path. +4. Report native qualification separately from the calculator's status, and + disclose account scope, currency, valuation basis, receipt window, calculable + return window, gaps/truncation and observation/end-flow method. + +The receipt window and return window differ. The pinned calculator uses the +first ending-equity observation as its opening anchor, groups observations/flows +by UTC end date and requires consecutive observed days. Three receipts covering +`t0→t1`, `t1→t2`, `t2→t3` across three successive end dates yield returns for +`t1→t2` and `t2→t3`; they do not establish a return for `t0→t1`. A single receipt +or multiple receipts ending on one UTC date are insufficient. The calculator +can retain only a later component when other inputs have a missing day; this +reader instead rejects a supplied receipt gap. Receipt contiguity and nonempty `ok` +therefore cannot certify a requested whole return window. Coverage disclosure is +the separately coordinated QPK consumer contract; this reader does not change it. +Do not invent an opening-equity point or label these observations exact TWR or +natural-midnight-day returns. Benchmark comparisons also need independently +qualified same-currency, same-return-window total-return inputs. + +## Offline preflight verification (2026-10-05) + +- The complete 185-file Binance fixed-main tree and modes match the local + baseline; the only proposed change is this document. All 184 other files, + including producer, reader, tests, runtime, pins and workflows, are unchanged +- All 496 blobs of the existing exact QPK pin were checked before execution +- The 35 existing reader tests and 37 existing source tests pass with network, + credential lookup and provider factories denied: zero attempts +- Additional synthetic observations confirm that a single receipt and two + receipts ending on one UTC date produce `insufficient_observations`; three + successive end dates produce two return points +- The documentation patch applies to the exact baseline and its relative source + link/section resolves. No production behavior changed, so no code RED/fix is + claimed. Full application tests and lint were not rerun for this docs-only + preflight; the historical results below are separate +- No native archive was acquired or qualified, and no protected data, broker, + credentials, runtime dispatch, publication, deployment or adoption was used + +## Original reader candidate verification (historical) - 35 new synthetic reader/consumer tests and 37 existing receipt fake tests pass with network and cloud factories denied: zero attempts From f591de0e7b4179bbe913ee7d4674bdc530cf4fb8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:54:31 +0000 Subject: [PATCH 26/47] build(deps): bump QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence Bumps [QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence](https://github.com/quantstrategylab/quantruntimesettings) from 685c0394ae097b36f33fe7cdeb24471c9b6ece06 to f2597e8bfb25881242b13145cb5f42d06ba3476c. - [Release notes](https://github.com/quantstrategylab/quantruntimesettings/releases) - [Commits](https://github.com/quantstrategylab/quantruntimesettings/compare/685c0394ae097b36f33fe7cdeb24471c9b6ece06...f2597e8bfb25881242b13145cb5f42d06ba3476c) --- updated-dependencies: - dependency-name: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence dependency-version: f2597e8bfb25881242b13145cb5f42d06ba3476c dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/runtime-target-lifecycle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/runtime-target-lifecycle.yml b/.github/workflows/runtime-target-lifecycle.yml index 0256d888..21fded8e 100644 --- a/.github/workflows/runtime-target-lifecycle.yml +++ b/.github/workflows/runtime-target-lifecycle.yml @@ -119,7 +119,7 @@ jobs: - name: Publish monitor execution evidence if: ${{ steps.target.outputs.configured_state == 'enabled' && steps.execution-heartbeat.outcome == 'success' }} - uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence@685c0394ae097b36f33fe7cdeb24471c9b6ece06 + uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence@f2597e8bfb25881242b13145cb5f42d06ba3476c with: source-id: runtime-reports-binance report-platform: binance From 562d0d60de4d2c6ebf9e66198a7c670cfc0d3de3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:56:53 +0000 Subject: [PATCH 27/47] build(deps): bump werkzeug from 3.1.8 to 3.1.9 Bumps [werkzeug](https://github.com/pallets/werkzeug) from 3.1.8 to 3.1.9. - [Release notes](https://github.com/pallets/werkzeug/releases) - [Changelog](https://github.com/pallets/werkzeug/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/werkzeug/compare/3.1.8...3.1.9) --- updated-dependencies: - dependency-name: werkzeug dependency-version: 3.1.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 92f3bb85..cda26d3c 100644 --- a/uv.lock +++ b/uv.lock @@ -1938,14 +1938,14 @@ wheels = [ [[package]] name = "werkzeug" -version = "3.1.8" +version = "3.1.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" }, ] [[package]] From fe1f229200c171081061f71182d51ec74377ee73 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:59:08 +0000 Subject: [PATCH 28/47] build(deps): bump QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle Bumps [QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle](https://github.com/quantstrategylab/quantruntimesettings) from 685c0394ae097b36f33fe7cdeb24471c9b6ece06 to f2597e8bfb25881242b13145cb5f42d06ba3476c. - [Release notes](https://github.com/quantstrategylab/quantruntimesettings/releases) - [Commits](https://github.com/quantstrategylab/quantruntimesettings/compare/685c0394ae097b36f33fe7cdeb24471c9b6ece06...f2597e8bfb25881242b13145cb5f42d06ba3476c) --- updated-dependencies: - dependency-name: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle dependency-version: f2597e8bfb25881242b13145cb5f42d06ba3476c dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/main.yml | 2 +- .github/workflows/runtime-target-lifecycle.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3c157eee..e5eeffaf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -221,7 +221,7 @@ jobs: - name: Publish disabled host observation if: ${{ steps.disabled-host.outputs.publish_ready == 'true' }} - uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle@685c0394ae097b36f33fe7cdeb24471c9b6ece06 + uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle@f2597e8bfb25881242b13145cb5f42d06ba3476c with: source-id: binance.runtime-target-lifecycle target-id: ${{ steps.disabled-host.outputs.target_id }} diff --git a/.github/workflows/runtime-target-lifecycle.yml b/.github/workflows/runtime-target-lifecycle.yml index 21fded8e..317cc9c2 100644 --- a/.github/workflows/runtime-target-lifecycle.yml +++ b/.github/workflows/runtime-target-lifecycle.yml @@ -138,7 +138,7 @@ jobs: run: python scripts/runtime_target_lifecycle_monitoring.py - name: Publish target lifecycle snapshot - uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle@685c0394ae097b36f33fe7cdeb24471c9b6ece06 + uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle@f2597e8bfb25881242b13145cb5f42d06ba3476c with: source-id: binance.runtime-target-lifecycle target-id: ${{ steps.target.outputs.target_id }} From a35c0ead612c08297bff8e7e5e38b8a3965dec54 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:02:54 +0000 Subject: [PATCH 29/47] build(deps): bump multidict from 6.7.1 to 6.9.1 Bumps [multidict](https://github.com/aio-libs/multidict) from 6.7.1 to 6.9.1. - [Release notes](https://github.com/aio-libs/multidict/releases) - [Changelog](https://github.com/aio-libs/multidict/blob/master/CHANGES.rst) - [Commits](https://github.com/aio-libs/multidict/compare/v6.7.1...v6.9.1) --- updated-dependencies: - dependency-name: multidict dependency-version: 6.9.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- uv.lock | 267 ++++++++++++++++++++++++++++++++------------------------ 1 file changed, 154 insertions(+), 113 deletions(-) diff --git a/uv.lock b/uv.lock index cda26d3c..c3ef1c05 100644 --- a/uv.lock +++ b/uv.lock @@ -1013,119 +1013,160 @@ wheels = [ [[package]] name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +version = "6.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/24/3823efc330630a1f132bcd0a9b182ddec3c53f452f551c8e8d3aaf5a2d20/multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4", size = 96739, upload-time = "2026-09-21T17:54:42.815Z" }, + { url = "https://files.pythonhosted.org/packages/6a/69/36331fe1d3aeb0c525a9972d6cf82791075a395aa96f026f060009d691ae/multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987", size = 58514, upload-time = "2026-09-21T17:54:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/5860651f782078ef13f2761c2593f4ba066d76f626b2f30f23a7f838b775/multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c", size = 57303, upload-time = "2026-09-21T17:54:45.304Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9f/c56e2fa223cb5d182660e1a09eec38880f96b7cd22769582f74447c60418/multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1", size = 320076, upload-time = "2026-09-21T17:54:46.826Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/eda4fe0cad51f96096363ed32d3e0f8df28dab00beff4d92adc9db517726/multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07", size = 315489, upload-time = "2026-09-21T17:54:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/14/10b1ded0a4085a51c3054125f854da4f47ce4fec724a2d2503231b83f3db/multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1", size = 296716, upload-time = "2026-09-21T17:54:49.749Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/04d155dd18528443cb9009f3a9f35f7417773797d072c95104ff17b8b9b7/multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3", size = 330244, upload-time = "2026-09-21T17:54:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/8b/90/284d10b3a9e5b312a8ec5b7a175fc7d560eefdc99886320449891a197992/multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80", size = 331288, upload-time = "2026-09-21T17:54:52.595Z" }, + { url = "https://files.pythonhosted.org/packages/27/50/f420de3683f9b047fc5588064d84043ef57a7152ef451b8373dfc7068d22/multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387", size = 320663, upload-time = "2026-09-21T17:54:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/01/ab/0120a650d7ce4fe167299c13a13cc4ee2dc72458a9c0e9e9330b824b73c9/multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345", size = 291139, upload-time = "2026-09-21T17:54:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/c66342f73ee5cd2c9b1dda6cfce89b5f348c48921b8c8b6e59aafbbfe31a/multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd", size = 310044, upload-time = "2026-09-21T17:54:56.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/0d/e44b90d9e77e44ec3473c4c7ac7ea95764d7e8fbf6d5a085ef9bff7ce0c2/multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f", size = 303279, upload-time = "2026-09-21T17:54:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/7741f7c23c211fae31ab546f0ba41569dae7671cf7ca30d5afb59e9d92aa/multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b", size = 326177, upload-time = "2026-09-21T17:54:59.825Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/0cdbfbbe36316b2ef4b8e011afaca2cf291a73fe4fabf34dd0b22e3ee48a/multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82", size = 323526, upload-time = "2026-09-21T17:55:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/0c86e9678e78f4596df0ed7f59d2aa755107da3f2a92f1b7fa150e94cbb8/multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953", size = 290278, upload-time = "2026-09-21T17:55:02.993Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/71bd7f43c4883cba6c5f96db88bd2311d52e74ad7680be5915938490d56f/multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a", size = 321142, upload-time = "2026-09-21T17:55:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/2d/00/bf59d6bb22a4c152e4039574c29490caf3c65107715121807b4bd7cde244/multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13", size = 319045, upload-time = "2026-09-21T17:55:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/f27f4f045198bf1c4bcd6eb3b6cf06be2dceaceb6e67222b903fce7a6749/multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f", size = 51309, upload-time = "2026-09-21T17:55:07.531Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/c2064aef64efc007bc89a571f843e5772f27946eb9bc018731bf8016e319/multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e", size = 59148, upload-time = "2026-09-21T17:55:08.936Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/3bab1827edd813dfb90712af5cbb7fc1473ed4d9c871d103df4f4bb950c2/multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d", size = 54258, upload-time = "2026-09-21T17:55:10.191Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, ] [[package]] From cca4ee6ec85ed9017a3c7074ac734256784d46f3 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:44:37 +0800 Subject: [PATCH 30/47] fix(notifications): honor zero BTC status report interval (#345) --- runtime_config_support.py | 9 ++++++- tests/test_runtime_config_support.py | 36 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/runtime_config_support.py b/runtime_config_support.py index d1dbb66e..2127db2c 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -30,6 +30,13 @@ def get_env_int(name: str, default: int) -> int: return int(default) +def _resolve_btc_status_report_interval_hours() -> int: + configured_hours = get_env_int("BTC_STATUS_REPORT_INTERVAL_HOURS", 24) + if configured_hours == 0: + return 0 + return max(1, min(24, configured_hours)) + + def get_env_bool(name: str, default: bool = False) -> bool: value = os.getenv(name) if value is None: @@ -86,7 +93,7 @@ def load_cycle_execution_settings() -> CycleExecutionSettings: metadata=strategy_metadata, ) return CycleExecutionSettings( - btc_status_report_interval_hours=max(1, min(24, get_env_int("BTC_STATUS_REPORT_INTERVAL_HOURS", 24))), + btc_status_report_interval_hours=_resolve_btc_status_report_interval_hours(), allow_new_trend_entries_on_degraded=get_env_bool( "STRATEGY_ARTIFACT_ALLOW_NEW_ENTRIES_ON_DEGRADED", False, diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 8983d2e5..34f3195d 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from pathlib import Path import sys +from types import SimpleNamespace from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] @@ -56,6 +57,41 @@ def test_load_cycle_execution_settings_clamps_interval_and_reads_degraded_flag(s self.assertEqual(settings.strategy_display_name_localized, "Crypto Live Pool Rotation") self.assertEqual(settings.strategy_domain, CRYPTO_DOMAIN) + def test_load_cycle_execution_settings_preserves_zero_and_interval_bounds(self): + cases = ( + ("missing", {}, 24), + ("empty", {"BTC_STATUS_REPORT_INTERVAL_HOURS": ""}, 24), + ("invalid", {"BTC_STATUS_REPORT_INTERVAL_HOURS": "not-an-int"}, 24), + ("zero", {"BTC_STATUS_REPORT_INTERVAL_HOURS": "0"}, 0), + ("negative", {"BTC_STATUS_REPORT_INTERVAL_HOURS": "-1"}, 1), + ("minimum", {"BTC_STATUS_REPORT_INTERVAL_HOURS": "1"}, 1), + ("maximum", {"BTC_STATUS_REPORT_INTERVAL_HOURS": "24"}, 24), + ("above_maximum", {"BTC_STATUS_REPORT_INTERVAL_HOURS": "48"}, 24), + ) + for name, env_updates, expected in cases: + with self.subTest(name=name), patch.dict(os.environ, env_updates, clear=True): + with ( + patch( + "runtime_config_support._resolve_runtime_target", + return_value=( + object(), + SimpleNamespace(profile="test-profile", domain=CRYPTO_DOMAIN), + ), + ), + patch( + "runtime_config_support.resolve_research_strategy_metadata", + return_value=SimpleNamespace(display_name="Test Strategy"), + ), + patch("runtime_config_support.get_notify_lang", return_value="en"), + patch("runtime_config_support.build_translator", return_value=object()), + patch( + "runtime_config_support.build_strategy_display_name", + return_value=lambda profile, **kwargs: "Test Strategy", + ), + ): + settings = load_cycle_execution_settings() + self.assertEqual(settings.btc_status_report_interval_hours, expected) + def test_load_cycle_execution_settings_ignores_legacy_trend_pool_degraded_alias(self): with patch.dict( os.environ, From 12c9937714c1d102d747d189d1043a3b2a255115 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:22:12 +0800 Subject: [PATCH 31/47] docs: rewrite governance templates into human-readable prose (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: rewrite governance templates into human-readable prose - README.md / README.zh-CN.md: add a natural-language intro paragraph after the title (what the repo is, its role in the QSL system, who it's for), and move the "QSL architecture role" field list out of the opening position into a mid-document "Architecture role" section, right before the operational sections (Runtime boundary, Deploy safely, Quick start, etc). No factual field content changed. - CONTRIBUTING.md / SECURITY.md: remove the templated "中文摘要" meta-description block that just restated the document's own purpose/coverage/reading order. The one piece of real information in it (dry-run/test-environment verification before touching live trading, credentials, Cloud Run, or exchange/broker APIs) is now a normal bullet under CONTRIBUTING.md's Ground Rules instead of a summary blurb; SECURITY.md already covers the same point in its own intro, so nothing needed to move there. No code or behavior changes. Co-Authored-By: Claude Sonnet 5 * docs: remove templated summary from CODE_OF_CONDUCT.md Drop the same mechanically-generated "## 中文摘要" meta-description block already removed from CONTRIBUTING.md and SECURITY.md. The one risk note it carried (discussions touching credentials, trading, or live systems should stay measured and evidence-based) wasn't explicitly stated in the body, so it's now folded as a plain sentence into the "Project Scope" section instead of a standalone summary. No other content changed. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- CODE_OF_CONDUCT.md | 9 +-------- CONTRIBUTING.md | 9 +-------- README.md | 17 ++++++----------- README.zh-CN.md | 17 ++++++----------- SECURITY.md | 8 -------- 5 files changed, 14 insertions(+), 46 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index dc87ca6e..d4564859 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,12 +1,5 @@ # Code of Conduct -## 中文摘要 - -- 用途:本文档说明 QuantStrategyLab 仓库中的讨论、issue、pull request 和 review 行为规范。 -- 主要覆盖:`Our Standards`、`Project Scope`、`Reporting and Enforcement`。 -- 阅读顺序:参与讨论或提交 PR 前先确认沟通边界;发现不当行为时联系维护者。 -- 风险提示:涉及投资、交易、密钥或实盘系统的讨论必须保持克制、可复现和证据导向。 - ## Our Standards - Be respectful, direct, and evidence-oriented in issues, pull requests, reviews, and discussions. @@ -16,7 +9,7 @@ ## Project Scope -QuantStrategyLab repositories involve research, automation, strategy artifacts, and trading-support systems. Contributions should keep financial claims conservative and verifiable, separate research evidence from live-trading decisions, and avoid presenting examples as investment advice. +QuantStrategyLab repositories involve research, automation, strategy artifacts, and trading-support systems. Contributions should keep financial claims conservative and verifiable, separate research evidence from live-trading decisions, and avoid presenting examples as investment advice. Discussions touching credentials, order execution, or other live systems should stay measured, reproducible, and evidence-based rather than speculative. ## Reporting and Enforcement diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2af60f44..ee854651 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,13 +1,5 @@ # Contributing - -## 中文摘要 - -- 用途:本文档围绕 `Contributing`,用于理解 `BinancePlatform` 的配置、运行、部署、研究或验收边界。 -- 主要覆盖:`Ground Rules`、`Branching and Pull Requests`、`Local Verification`。 -- 阅读顺序:先确认边界、输入输出和权限要求,再执行文档里的命令、CI、dry-run、发布或切换步骤。 -- 风险提示:涉及实盘、密钥、权限、Cloud Run、交易所或券商 API 的变更,必须先在测试环境或 dry-run 验证;不要只凭示例直接修改生产。 -- 英文正文保留更完整的命令、字段名和配置键;如果摘要和正文不一致,以正文中的实际命令和配置为准。 Thanks for contributing to `BinancePlatform`. ## Ground Rules @@ -16,6 +8,7 @@ Thanks for contributing to `BinancePlatform`. - Keep refactors separate from behavior changes. - Add or update tests when changing runtime behavior. - Do not use deployment or scheduled workflows as a substitute for local verification. +- Changes touching live trading, credentials, permissions, Cloud Run, or exchange/broker APIs must be verified in a test environment or dry-run first; do not edit production based on the examples alone. ## Branching and Pull Requests diff --git a/README.md b/README.md index e0f70ea4..bfa9a03e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@ # BinancePlatform +BinancePlatform is QuantStrategyLab's execution runtime for Binance crypto trading: it runs runtime-enabled crypto strategies through Binance-facing workflows and self-hosted orchestration, handling broker/API connectivity, dry-run/live controls, and deployment. It is strictly an execution layer, not a research repository — strategy logic comes from `CryptoStrategies`, and live-pool eligibility plus validation artifacts come from `CryptoLivePoolPipelines` when a profile needs them. Within the larger QuantStrategyLab system it sits at the runtime-platform layer, consuming upstream strategy and pipeline artifacts rather than deciding strategy logic itself. It's meant for engineers operating or auditing this platform's deployment and runtime behavior, not for traders looking for ready-made strategies or investment signals. -## QSL architecture role +[Chinese README](README.zh-CN.md) + +> Investing involves risk. This project does not provide investment advice and is for education, research, and engineering review only. + +## Architecture role - **Layer**: `runtime-platform`. - **Responsibility**: Binance crypto execution runtime. @@ -9,16 +14,6 @@ - **Consumes**: CryptoStrategies, CryptoLivePoolPipelines artifacts, QuantPlatformKit, QuantRuntimeSettings. - **Must not**: own strategy research logic or publish live-pool membership. -[Chinese README](README.zh-CN.md) - -> Investing involves risk. This project does not provide investment advice and is for education, research, and engineering review only. - -## What this repository is - -BinancePlatform is a QuantStrategyLab Binance crypto execution platform. It executes runtime-enabled crypto strategies through Binance-facing workflows and self-hosted orchestration. - -It is an execution layer, not a strategy research repository. Strategy logic comes from `CryptoStrategies`; live-pool and validation artifacts come from `CryptoLivePoolPipelines` when a profile requires them. - ## Runtime boundary - Loads only runtime-enabled strategy profiles exposed by the strategy packages. diff --git a/README.zh-CN.md b/README.zh-CN.md index 999af653..ac1a1946 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,7 +1,12 @@ # BinancePlatform +BinancePlatform 是 QuantStrategyLab 的 Binance 加密货币执行 runtime:它通过面向 Binance 的 workflow 和 self-hosted 编排来运行已启用的加密货币策略,负责券商/API 连接、dry-run/live 控制和部署配置。它只是执行层,不是研究仓库——策略逻辑来自 `CryptoStrategies`,如果 profile 依赖 live-pool,相关验证和产物来自 `CryptoLivePoolPipelines`。在整个 QuantStrategyLab 多仓库系统里,它处于 runtime-platform 层,消费上游策略和 pipeline 产物,而不自行决定策略逻辑。它主要面向负责运维或审查该平台部署与运行行为的工程师,而不是寻找现成策略或投资信号的交易者。 -## QSL 架构角色 +[English README](README.md) + +> 投资有风险。本项目不构成投资建议,仅用于学习、研究和工程审阅。 + +## 架构角色 - **层级**:`执行平台`。 - **职责**:Binance 加密执行运行时。 @@ -9,16 +14,6 @@ - **消费对象**:CryptoStrategies、CryptoLivePoolPipelines artifacts、QuantPlatformKit、QuantRuntimeSettings。 - **禁止事项**:承载策略研究逻辑或发布 live-pool 成员。 -[English README](README.md) - -> 投资有风险。本项目不构成投资建议,仅用于学习、研究和工程审阅。 - -## 这个仓库是什么 - -BinancePlatform 是 QuantStrategyLab 的Binance 加密货币执行平台。通过面向 Binance 的 workflow 和 self-hosted 编排执行 runtime-enabled 加密货币策略。 - -它属于执行层,不是策略研究仓库。策略逻辑来自 `CryptoStrategies`;如果 profile 依赖 live-pool,验证和产物来自 `CryptoLivePoolPipelines`。 - ## 运行边界 - 只加载策略包暴露的 runtime-enabled profile。 diff --git a/SECURITY.md b/SECURITY.md index 9dace975..780f4281 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,13 +1,5 @@ # Security Policy - -## 中文摘要 - -- 用途:本文档围绕 `Security Policy`,用于理解 `BinancePlatform` 的配置、运行、部署、研究或验收边界。 -- 主要覆盖:`Reporting a Vulnerability`、`Secret and Credential Exposure`、`Scope Notes`。 -- 阅读顺序:先确认边界、输入输出和权限要求,再执行文档里的命令、CI、dry-run、发布或切换步骤。 -- 风险提示:涉及实盘、密钥、权限、Cloud Run、交易所或券商 API 的变更,必须先在测试环境或 dry-run 验证;不要只凭示例直接修改生产。 -- 英文正文保留更完整的命令、字段名和配置键;如果摘要和正文不一致,以正文中的实际命令和配置为准。 Thanks for helping keep `BinancePlatform` safe. This repository is part of a runtime trading platform. Please do **not** open a public issue for vulnerabilities involving credentials, broker access, cloud resources, order execution, or secret material. From 396cb03567842c1b7a490ffee7ff0f103ab2a445 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:25:09 +0800 Subject: [PATCH 32/47] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=9C=AA=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E7=9A=84=20Codex=20App=20=E5=AE=A1=E6=9F=A5=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=20(#349)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Codex --- scripts/gate_codex_app_review.py | 284 ------------------------------- 1 file changed, 284 deletions(-) delete mode 100644 scripts/gate_codex_app_review.py diff --git a/scripts/gate_codex_app_review.py b/scripts/gate_codex_app_review.py deleted file mode 100644 index ac01b5c9..00000000 --- a/scripts/gate_codex_app_review.py +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env python3 -"""PR merge gate: static scan + Codex App review → job exit code = check status. - -Two phases, zero API keys needed: - 1. STATIC — scan diff for secrets, blocked files, metadata issues (<30s). - Fail job immediately on hard violations. - 2. WAIT — poll for Codex GitHub App review up to N min. - Fail job on CHANGES_REQUESTED, pass on APPROVED/timeout. - 3. REACT — on Codex bot review submitted: update instantly. - -The workflow job IS the check — exit 0 = pass, exit 1 = fail. -""" - -from __future__ import annotations - -import json -import os -import re -import sys -import time -import urllib.error -import urllib.request -from pathlib import Path -from typing import Any - -API_BASE = "https://api.github.com" -BOT_LOGIN = "chatgpt-codex-connector[bot]" -POLICY_PATH = Path(".github/codex_auto_merge_policy.json") - - -def env(name: str, default: str = "") -> str: - return os.environ.get(name, default).strip() - - -def env_int(name: str, default: int) -> int: - try: return int(env(name, str(default))) - except ValueError: return default - - -def github_request(token: str, method: str, path: str, - payload: dict[str, Any] | None = None) -> Any: - url = f"{API_BASE}{path}" if not path.startswith("https://") else path - data = json.dumps(payload).encode() if payload else None - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "codex-review-gate", - } - if payload: headers["Content-Type"] = "application/json" - req = urllib.request.Request(url, data=data, method=method, headers=headers) - try: - with urllib.request.urlopen(req, timeout=30) as resp: - body = resp.read().decode("utf-8") - except urllib.error.HTTPError as exc: - detail = exc.read().decode("utf-8", errors="replace") - raise RuntimeError(f"GitHub API {method} {url}: {exc.code} {detail[:500]}") from exc - return json.loads(body) if body else {} - - -def step_summary(text: str) -> None: - p = os.environ.get("GITHUB_STEP_SUMMARY", "") - if p: - with open(p, "a", encoding="utf-8") as f: - f.write(text + "\n") - - -# ─── policy ────────────────────────────────────────────────────────────────── - -def load_policy() -> dict[str, Any]: - if POLICY_PATH.exists(): - try: return json.loads(POLICY_PATH.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): pass - return { - "version": 1, - "blocked_path_patterns": [ - r"(^|/)(\.env|.*secret.*|.*credential.*|.*token.*|.*private.*|.*\.pem|.*\.key)$", - ], - "max_changed_files": 50, - "max_changed_lines": 5000, - } - - -def compile_patterns(policy: dict[str, Any]) -> list[re.Pattern[str]]: - pp: list[re.Pattern[str]] = [] - for p in policy.get("blocked_path_patterns", []): - if isinstance(p, str) and p.strip(): - try: pp.append(re.compile(p, re.IGNORECASE)) - except re.error: pass - return pp - - -# ─── static guard ──────────────────────────────────────────────────────────── - -_SENSITIVE = re.compile( - r'(?:api[_\s]?key|secret|password|token|credential|private[_\s]?key)\s*[:=]\s*["\']' - r'(?!\$\{\{|{{|example|placeholder|test|your[-_\s]|xxx|TODO|CHANGEME)[^"\']{12,}["\']', - re.IGNORECASE, -) - - -def scan_diff(diff_text: str, path_patterns: list[re.Pattern[str]]) -> list[str]: - violations: list[str] = [] - current = "" - for line in diff_text.splitlines(): - if line.startswith("diff --git "): - parts = line.split(" ") - current = parts[3][2:] if len(parts) >= 4 and parts[3].startswith("b/") else "" - for pat in path_patterns: - if current and pat.search(current): - violations.append(f"**Blocked file**: `{current}` matches `{pat.pattern}`") - break - continue - if line.startswith("+++ b/"): current = line[6:]; continue - if not line.startswith("+") or line.startswith("+++"): continue - m = _SENSITIVE.search(line[1:]) - if m: - violations.append(f"**Hardcoded secret** in `{current}`: `{m.group(0)[:100]}`") - return list(dict.fromkeys(violations)) - - -def check_metadata(files: list[dict[str, Any]], policy: dict[str, Any]) -> list[str]: - issues: list[str] = [] - mx_f = policy.get("max_changed_files", 50) - mx_l = policy.get("max_changed_lines", 5000) - ta = sum(f.get("additions", 0) or 0 for f in files) - td = sum(f.get("deletions", 0) or 0 for f in files) - for f in files: - fn = f.get("filename", "?") - st = (f.get("status") or "").lower().strip() - if st == "removed": issues.append(f"**File deleted**: `{fn}` — verify intentional") - elif st == "renamed": issues.append(f"**File renamed**: `{f.get('previous_filename', '?')}` → `{fn}`") - if len(files) > mx_f: - issues.append(f"**Too many files**: {len(files)} changed (limit {mx_f})") - if ta + td > mx_l: - issues.append(f"**Too many lines**: {ta + td} changed (limit {mx_l})") - return issues - - -def run_static_guard(token: str, repo: str, pr_number: int) -> int: - """Return 0 if clean, 1 if blocked.""" - policy = load_policy() - files: list[dict[str, Any]] = [] - page = 1 - while True: - try: - batch = github_request(token, "GET", - f"/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}") - except RuntimeError: break - if not isinstance(batch, list) or not batch: break - files.extend(batch) - if len(batch) < 100: break - page += 1 - - diff_text = "" - try: - req = urllib.request.Request( - f"{API_BASE}/repos/{repo}/pulls/{pr_number}", - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github.v3.diff", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "codex-review-gate", - }, - ) - with urllib.request.urlopen(req, timeout=30) as resp: - diff_text = resp.read().decode("utf-8", errors="replace") - except Exception: pass - - issues = check_metadata(files, policy) + scan_diff(diff_text, compile_patterns(policy)) - if not issues: return 0 - - print(f"STATIC → BLOCKED: {len(issues)} issue(s)") - for i in issues: print(f" • {i}") - step_summary(f"## Merge blocked: {len(issues)} static issue(s)\n\n" + - "\n".join(f"- {i}" for i in issues)) - return 1 - - -# ─── app review ────────────────────────────────────────────────────────────── - -def get_codex_review(token: str, repo: str, pr_number: int) -> dict[str, Any] | None: - reviews = github_request(token, "GET", f"/repos/{repo}/pulls/{pr_number}/reviews?per_page=100") - if not isinstance(reviews, list): return None - for r in reversed(reviews): - if isinstance(r, dict) and (r.get("user") or {}).get("login") == BOT_LOGIN: - return r - return None - - -def app_decision(review: dict[str, Any] | None) -> tuple[int, str, str]: - """(exit_code, title, summary)""" - if review is None: - return (0, "Codex: no review — passed through", - "Codex did not respond in time. Merge allowed to avoid blocking development.") - state = (review.get("state") or "").strip().upper() - url = review.get("html_url", "") - body = (review.get("body") or "").strip() - at = review.get("submitted_at", "") - - if state == "CHANGES_REQUESTED": - snippet = (body[:500] + "...") if len(body) > 500 else body - return (1, "Codex: changes requested — MERGE BLOCKED", - f"Codex **requested changes** at {at}.\n\n{snippet}\n\n[View review]({url})") - if state == "APPROVED": - return (0, "Codex: approved", f"Codex approved at {at}. [View review]({url})") - return (0, f"Codex: reviewed ({state.lower()})", - f"Codex state `{state}` at {at}. Not blocking. [View review]({url})") - - -# ─── main ──────────────────────────────────────────────────────────────────── - -def main() -> int: - token = env("GH_TOKEN") or env("GITHUB_TOKEN") - repo = env("GITHUB_REPOSITORY") - if not token or not repo: - print("::error::GH_TOKEN + GITHUB_REPOSITORY required", file=sys.stderr) - return 1 - - event_path = Path(os.environ.get("GITHUB_EVENT_PATH", "")) - if not event_path.exists(): - print("::error::GITHUB_EVENT_PATH missing", file=sys.stderr) - return 1 - - event = json.loads(event_path.read_text(encoding="utf-8")) - event_name = env("GITHUB_EVENT_NAME", "") - pr = event.get("pull_request") or {} - pr_number = pr.get("number") - head_sha = (pr.get("head") or {}).get("sha") - if not pr_number or not head_sha: - print(f"::warning::Cannot resolve PR context"); return 0 - - print(f"PR #{pr_number} sha={head_sha[:12]} event={event_name}") - - # ── Phase 1: Static guard (skip on review-only events) ──────────── - if event_name != "pull_request_review": - try: rc = run_static_guard(token, repo, pr_number) - except RuntimeError as exc: - print(f"::warning::Static guard error: {exc}"); rc = 0 - if rc != 0: return 1 - print("STATIC → clean") - - # ── Phase 2: App review ─────────────────────────────────────────── - # REACT: Codex just submitted a review - review_event = event.get("review") or {} - if event_name == "pull_request_review" and (review_event.get("user") or {}).get("login") == BOT_LOGIN: - rc, title, summary = app_decision(review_event) - print(f"REACT → exit={rc}: {title}") - step_summary(f"## {title}\n\n{summary}") - return rc - - # WAIT: poll for existing or upcoming review - try: existing = get_codex_review(token, repo, pr_number) - except RuntimeError: existing = None - - if existing is not None: - rc, title, summary = app_decision(existing) - print(f"EXISTING → exit={rc}: {title}") - step_summary(f"## {title}\n\n{summary}") - return rc - - poll_s = env_int("CODEX_GATE_POLL_SECONDS", 30) - max_w = env_int("CODEX_GATE_MAX_WAIT_MINUTES", 5) - deadline = time.time() + max_w * 60 - print(f"WAIT → polling every {poll_s}s for up to {max_w}min") - - while time.time() < deadline: - time.sleep(poll_s) - try: review = get_codex_review(token, repo, pr_number) - except RuntimeError: continue - if review is not None: - rc, title, summary = app_decision(review) - print(f"WAIT → found review → exit={rc}: {title}") - step_summary(f"## {title}\n\n{summary}") - return rc - - # Timeout - print(f"TIMEOUT → Codex did not respond in {max_w}min; passing through") - step_summary(f"## Codex: timeout after {max_w}min\n\nPassed through to avoid blocking development.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From a011e56d23f9f1059bed2fe01177cd4a9a9f4db4 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:32:19 +0800 Subject: [PATCH 33/47] Use scoped GitHub App tokens for authority maintenance (#350) * Use scoped GitHub App tokens for Binance authority maintenance Co-Authored-By: Codex * Add read-only authority App authentication check Co-Authored-By: Codex --------- Co-authored-by: Codex --- .../workflows/authority-app-auth-check.yml | 46 +++++++++++++ .../workflows/authority-version-reissue.yml | 24 +++++-- docs/authority_reissue.md | 51 +++++++++++++-- docs/authority_runner_revision_update.md | 29 +++++++-- tests/test_authority_reissue_workflow.py | 64 +++++++++++++++++-- 5 files changed, 196 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/authority-app-auth-check.yml diff --git a/.github/workflows/authority-app-auth-check.yml b/.github/workflows/authority-app-auth-check.yml new file mode 100644 index 00000000..4863b5b6 --- /dev/null +++ b/.github/workflows/authority-app-auth-check.yml @@ -0,0 +1,46 @@ +name: Authority App authentication check + +# Configuration access only: no authority materialization, writes or trading. +on: + workflow_dispatch: + +permissions: {} + +jobs: + authenticate: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + environment: binance-runtime + steps: + - name: Mint scoped read-only installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.BINANCE_AUTHORITY_APP_CLIENT_ID }} + private-key: ${{ secrets.BINANCE_AUTHORITY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-actions: read + permission-environments: read + permission-metadata: read + env: + # v3.2.0 parses this undeclared input into actions_variables. + INPUT_PERMISSION-ACTIONS-VARIABLES: read + + - name: Verify installation scope and configuration reads + shell: bash + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + actual=$(gh api installation/repositories --jq '.repositories | map(.full_name) | sort | join(",")') + if [ "$actual" != "$REPOSITORY" ]; then + echo "::error::Installation token repository scope does not match." + exit 1 + fi + gh api --silent "repos/${REPOSITORY}/actions/runs?per_page=1" + gh api --silent "repos/${REPOSITORY}/actions/variables/RUNTIME_TARGET_ENABLED" + gh api --silent "repos/${REPOSITORY}/environments/binance-runtime/variables/BINANCE_AUTHORITY_APP_CLIENT_ID" + gh api --silent "repos/${REPOSITORY}/environments/binance-runtime/secrets/public-key" + echo "Scoped GitHub App authentication verified; no configuration writes." diff --git a/.github/workflows/authority-version-reissue.yml b/.github/workflows/authority-version-reissue.yml index 03a33540..4f80edc0 100644 --- a/.github/workflows/authority-version-reissue.yml +++ b/.github/workflows/authority-version-reissue.yml @@ -3,8 +3,8 @@ run-name: Authority version reissue · ${{ inputs.mode }} # One-shot maintenance only. Does not enable trading, does not alter RECONCILE_ONLY # or risk mandate, and does not dispatch Runtime / no-submit full_cycle. -# The scoped environment token supplies GitHub configuration reads in both -# modes. Only apply passes it to the write gateway. This workflow is not +# A scoped GitHub App installation token supplies configuration access in +# both modes. Only apply passes it to the write gateway. This workflow is not # production-ready until that identity and branch/environment protections are # configured by operators. @@ -61,6 +61,22 @@ jobs: ref: ${{ github.sha }} persist-credentials: false + - name: Mint scoped authority maintenance token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.BINANCE_AUTHORITY_APP_CLIENT_ID }} + private-key: ${{ secrets.BINANCE_AUTHORITY_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-actions: read + permission-environments: ${{ inputs.mode == 'apply' && 'write' || 'read' }} + permission-metadata: read + env: + # v3.2.0's INPUT_PERMISSION_* parser maps this to actions_variables; + # its declared inputs omit the variables permission. + INPUT_PERMISSION-ACTIONS-VARIABLES: read + - name: Materialize authority secret to a 0600 temp file id: materialize shell: bash @@ -82,7 +98,7 @@ jobs: id: reissue shell: bash env: - GH_TOKEN: ${{ secrets.BINANCE_AUTHORITY_UPDATE_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} UPDATE_MODE: ${{ inputs.mode }} TARGET_STRATEGY_REVISION: ${{ inputs.target_strategy_revision }} TARGET_RUNNER_REVISION: ${{ inputs.target_runner_revision }} @@ -92,7 +108,7 @@ jobs: AUTHORITY_FILE: ${{ steps.materialize.outputs.authority_file }} MAINTENANCE_WORKFLOW_SHA: ${{ github.sha }} MAINTENANCE_RUN_ID: ${{ github.run_id }} - BINANCE_AUTHORITY_UPDATE_TOKEN: ${{ inputs.mode == 'apply' && secrets.BINANCE_AUTHORITY_UPDATE_TOKEN || '' }} + BINANCE_AUTHORITY_UPDATE_TOKEN: ${{ inputs.mode == 'apply' && steps.app-token.outputs.token || '' }} run: | set -euo pipefail python3 scripts/update_binance_authority_reissue.py \ diff --git a/docs/authority_reissue.md b/docs/authority_reissue.md index df94e28c..681009e6 100644 --- a/docs/authority_reissue.md +++ b/docs/authority_reissue.md @@ -23,14 +23,53 @@ operator-supplied target SHA. ## Modes - `plan` (default): verify live preconditions + byte digest + surgical patch; - **zero** secret/variable writes. It uses the scoped environment token only - to read the current GitHub configuration; the script clears its write-token - input before executing the plan. + **zero** secret/variable writes. It uses a scoped GitHub App installation + token to read the current GitHub configuration; the script clears its + write-token input before executing the plan. - `apply`: same checks, then submit secret bytes, the new SHA256 variable, and the new source revision variable (skipping any write that is already at - target). Requires environment secret `BINANCE_AUTHORITY_UPDATE_TOKEN` - (fine-grained **Environments: write**). Ordinary `GITHUB_TOKEN` cannot write - environment secrets; do not invent `permissions: secrets: write`. + target). The same installation token is passed to the write gateway only in + apply mode. + +## GitHub App maintenance identity + +Create a dedicated private organization GitHub App and install it only on +`QuantStrategyLab/BinancePlatform`. Grant the installation **Actions: read**, +**Variables: read**, **Environments: write**, and **Metadata: read**. The +workflow downscopes each minted token to this repository; plan requests +**Environments: read**, while apply requests **Environments: write**. GitHub +App Environments permission applies repository-wide and cannot be restricted +to the named `binance-runtime` environment. Keeping the App private key as a +secret in the protected `binance-runtime` environment limits which workflow +jobs can access that key. No webhook or OAuth configuration is needed. + +Set `BINANCE_AUTHORITY_APP_CLIENT_ID` as an environment variable and +`BINANCE_AUTHORITY_APP_PRIVATE_KEY` as an environment secret in +`binance-runtime`. Each workflow run mints an installation token that expires +within one hour; the action attempts to revoke it in its post-job step. A +revocation failure is reported as a warning; expiry still limits its lifetime. +The private key +remains long-lived until manually revoked or rotated. Keep the existing +`BINANCE_AUTHORITY_UPDATE_TOKEN` PAT until the App identity has been validated +with the maintenance workflow, then retire the PAT. + +The current `binance-runtime` deployment branch policy allows only +`runtime-production`. A change merged to `main` alone is not adopted by that +maintenance entry point. Promote the authentication change through the approved +release path and verify the dispatched revision; do not widen the environment +branch policy to make validation pass. + +After that adoption, dispatch `authority-app-auth-check.yml` on +`runtime-production` to verify installation-token creation, repository scope, +and configuration reads. This separate manual check does not read the authority +JSON, write configuration, or dispatch trading; it requires no authority-update +inputs and can run without stopping a trading runtime. + +Before adopting a maintenance change on `runtime-production`, compare its +current revision with `BINANCE_RUNTIME_WORKFLOW_SHA`. Updating the branch also +changes the runtime workflow revision. Do not advance the approved runtime pin +or include unrelated runtime changes merely to validate authentication; those +changes require their own approved release decision. ## Explicit inputs (no main-tip selection) diff --git a/docs/authority_runner_revision_update.md b/docs/authority_runner_revision_update.md index a8992614..139c6326 100644 --- a/docs/authority_runner_revision_update.md +++ b/docs/authority_runner_revision_update.md @@ -22,9 +22,29 @@ digest, mandate, and `BINANCE_RISK_AUTHORITY_SOURCE_REVISION` stay unchanged. - `plan` (default): verify live preconditions + byte digest + surgical patch; **zero** secret/variable writes. Do not inject the write token. - `apply`: same checks, then submit secret bytes and the new SHA256 variable. - Requires environment secret `BINANCE_AUTHORITY_UPDATE_TOKEN` (fine-grained - **Environments: write** on this repository). Ordinary `GITHUB_TOKEN` cannot - write environment secrets; do not invent `permissions: secrets: write`. + The workflow passes its per-run GitHub App installation token through the + existing `BINANCE_AUTHORITY_UPDATE_TOKEN` process environment only in apply + mode. No saved PAT is required by the script. + +## GitHub App maintenance identity + +Use a dedicated private organization GitHub App installed only on +`QuantStrategyLab/BinancePlatform`, with **Actions: read**, **Variables: read**, +**Environments: write**, and **Metadata: read**. The workflow scopes each +installation token to this repository and requests **Environments: read** for +plan or **Environments: write** for apply. GitHub App Environments permission +is repository-wide; the protected `binance-runtime` environment that stores +the App private key controls which workflow jobs can access it. No webhook or +OAuth configuration is needed. + +Store `BINANCE_AUTHORITY_APP_CLIENT_ID` as an environment variable and +`BINANCE_AUTHORITY_APP_PRIVATE_KEY` as an environment secret in +`binance-runtime`. The action mints a token for each run, valid for at most one +hour, and attempts to revoke it in the post-job step. A revocation failure is +reported as a warning; expiry still limits its lifetime. The private key remains long-lived +until manually revoked or rotated. Retain the existing +`BINANCE_AUTHORITY_UPDATE_TOKEN` PAT until the new App identity is validated, +then retire the PAT. ## Preconditions (fail closed) @@ -57,7 +77,8 @@ external `gh`/console writers; operators must coordinate before `apply`. ## Not production-ready until -- `BINANCE_AUTHORITY_UPDATE_TOKEN` is created out-of-band (never paste into chat). +- GitHub App client ID and private key are configured out-of-band in + `binance-runtime`. - Environment/branch protection and human approval gates are confirmed for `binance-runtime` / `main` as required by operators. - Version triangle is decided: maintenance workflow revision, approved trading diff --git a/tests/test_authority_reissue_workflow.py b/tests/test_authority_reissue_workflow.py index f37fee69..956b1bd6 100644 --- a/tests/test_authority_reissue_workflow.py +++ b/tests/test_authority_reissue_workflow.py @@ -1,16 +1,72 @@ from pathlib import Path - WORKFLOW = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "authority-version-reissue.yml" +def _step(workflow: str, name: str) -> str: + marker = f" - name: {name}\n" + start = workflow.index(marker) + end = workflow.find(" - name:", start + len(marker)) + return workflow[start:] if end == -1 else workflow[start:end] + + def test_authority_reissue_uses_an_isolated_hosted_runner() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") assert "runs-on: ubuntu-24.04" in workflow assert "runs-on: self-hosted" not in workflow - assert "GH_TOKEN: ${{ secrets.BINANCE_AUTHORITY_UPDATE_TOKEN }}" in workflow + assert "timeout-minutes: 15" in workflow + assert "persist-credentials: false" in workflow + + +def test_app_token_is_scoped_and_wired_before_authority_materialization() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + mint = _step(workflow, "Mint scoped authority maintenance token") + reissue = _step(workflow, "Plan or apply authority version reissue") + + assert workflow.index(mint) < workflow.index( + _step(workflow, "Materialize authority secret to a 0600 temp file") + ) + assert "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1" in mint + assert "client-id: ${{ vars.BINANCE_AUTHORITY_APP_CLIENT_ID }}" in mint + assert "private-key: ${{ secrets.BINANCE_AUTHORITY_APP_PRIVATE_KEY }}" in mint + assert "owner: ${{ github.repository_owner }}" in mint + assert "repositories: ${{ github.event.repository.name }}" in mint + assert "permission-actions: read" in mint + assert "INPUT_PERMISSION-ACTIONS-VARIABLES: read" in mint + assert "permission-variables: read" not in mint + assert "permission-environments: ${{ inputs.mode == 'apply' && 'write' || 'read' }}" in mint + assert "permission-metadata: read" in mint + assert "permission-contents:" not in mint + assert "permission-administration:" not in mint + assert "permission-secrets:" not in mint + assert "skip-token-revoke:" not in mint + + assert "GH_TOKEN: ${{ steps.app-token.outputs.token }}" in reissue assert ( "BINANCE_AUTHORITY_UPDATE_TOKEN: ${{ inputs.mode == 'apply' " - "&& secrets.BINANCE_AUTHORITY_UPDATE_TOKEN || '' }}" - ) in workflow + "&& steps.app-token.outputs.token || '' }}" + ) in reissue + assert "secrets.BINANCE_AUTHORITY_UPDATE_TOKEN" not in workflow + + +def test_manual_inputs_and_existing_stop_contract_remain() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + docs = (WORKFLOW.parents[2] / "docs" / "authority_reissue.md").read_text(encoding="utf-8") + + assert "workflow_dispatch:" in workflow + assert "options: [plan, apply]" in workflow + assert "schedule:" not in workflow + assert "does not enable trading" in workflow.lower() + assert "does not dispatch runtime / no-submit full_cycle" in workflow.lower() + for name in ( + "target_strategy_revision", + "target_runner_revision", + "target_source_revision", + "expected_authority_sha256", + "expected_source_revision", + ): + assert f" {name}:" in workflow + assert f" {name}:\n description:" in workflow + assert "RUNTIME_TARGET_ENABLED` live value is exactly `false`" in docs + assert "first failure or uncertain outcome: stop" in docs From 1aa5b390c3829b22ca8ba67a70bbddffc2412761 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:09:11 +0800 Subject: [PATCH 34/47] fix: separate bound account facts reads from Runtime success Co-Authored-By: Codex --- .github/workflows/binance-account-facts.yml | 56 +++++- scripts/read_binance_account_facts.py | 137 +++++++++++--- tests/test_binance_account_facts.py | 200 +++++++++++++++++++- 3 files changed, 356 insertions(+), 37 deletions(-) diff --git a/.github/workflows/binance-account-facts.yml b/.github/workflows/binance-account-facts.yml index f6ad64d5..15081f0a 100644 --- a/.github/workflows/binance-account-facts.yml +++ b/.github/workflows/binance-account-facts.yml @@ -16,7 +16,7 @@ name: Binance Account Facts workflow_dispatch: inputs: enabled: - description: "Run the existing terminal-report read; repository gate must also be true." + description: "Run the manual read-only account-facts read; repository gate must also be true." required: false type: boolean default: false @@ -25,10 +25,10 @@ name: Binance Account Facts required: false type: string source_mode: - description: "Keep this workflow_dispatch on the single legacy terminal-report path." + description: "Read directly from the bound account, or use the legacy Runtime report." required: true type: choice - options: [legacy_terminal] + options: [direct_read, legacy_terminal] permissions: actions: read @@ -44,10 +44,13 @@ jobs: read-and-publish: if: >- vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true' && - github.ref == 'refs/heads/runtime-production' && - ((inputs.enabled == true && inputs.source_mode == 'same_parent') || - (github.workflow == 'Binance Account Facts' && github.event_name == 'workflow_dispatch' && - inputs.enabled == true && inputs.source_mode == 'legacy_terminal')) + ((github.event_name == 'workflow_dispatch' && github.workflow == 'Binance Account Facts' && + inputs.enabled == true && inputs.source_mode == 'direct_read' && + github.ref == 'refs/heads/main') || + (github.ref == 'refs/heads/runtime-production' && + ((inputs.enabled == true && inputs.source_mode == 'same_parent') || + (github.workflow == 'Binance Account Facts' && github.event_name == 'workflow_dispatch' && + inputs.enabled == true && inputs.source_mode == 'legacy_terminal')))) runs-on: self-hosted timeout-minutes: 5 continue-on-error: true @@ -62,7 +65,7 @@ jobs: - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9 - - name: Verify trusted checkout and exact Runtime source + - name: Verify trusted reader checkout and source mode id: preflight shell: bash env: @@ -71,13 +74,21 @@ jobs: BINANCE_RUNTIME_WORKFLOW_SHA: ${{ vars.BINANCE_RUNTIME_WORKFLOW_SHA || '' }} SOURCE_MODE: ${{ inputs.source_mode || 'legacy_terminal' }} SOURCE_RUN_ID: ${{ inputs.source_run_id || '' }} - GITHUB_TOKEN: ${{ github.token }} + READER_PUBLIC_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} + GITHUB_TOKEN: ${{ inputs.source_mode != 'direct_read' && github.token || '' }} GITHUB_API_URL: ${{ github.api_url }} run: | set -euo pipefail + expected_ref=refs/heads/runtime-production + if [ "$SOURCE_MODE" = "direct_read" ]; then + expected_ref=refs/heads/main + elif [ "$SOURCE_MODE" != "same_parent" ] && [ "$SOURCE_MODE" != "legacy_terminal" ]; then + echo "account_facts_source_mode_invalid" >&2 + exit 1 + fi if [[ ! "$BINANCE_ACCOUNT_FACTS_READER_REVISION" =~ ^[0-9a-f]{40}$ ]] || \ [ "$(git rev-parse HEAD)" != "$BINANCE_ACCOUNT_FACTS_READER_REVISION" ] || \ - [ "$GITHUB_REF" != "refs/heads/runtime-production" ]; then + [ "$GITHUB_REF" != "$expected_ref" ]; then echo "account_facts_trusted_checkout_invalid" >&2 exit 1 fi @@ -94,6 +105,7 @@ jobs: chmod 700 "$private_dir" - name: Download only the exact successful Runtime report artifact + if: ${{ inputs.source_mode != 'direct_read' }} uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: binance-execution-report-${{ steps.preflight.outputs.source_run_id }} @@ -104,6 +116,7 @@ jobs: - name: Read the bound Spot and Flexible Earn account quantities id: read-account-facts + if: ${{ inputs.source_mode != 'direct_read' }} shell: bash env: FACTS_DIR: ${{ steps.private-dir.outputs.facts_dir }} @@ -133,7 +146,30 @@ jobs: --report "$FACTS_DIR/source/execution_report.json" \ --output "$FACTS_DIR/account_facts.json" + - name: Read directly from the protected account binding + id: read-direct-account-facts + if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_mode == 'direct_read' }} + shell: bash + env: + FACTS_DIR: ${{ steps.private-dir.outputs.facts_dir }} + SOURCE_MODE: direct_read + BINANCE_ACCOUNT_FACTS_ENABLED: ${{ vars.BINANCE_ACCOUNT_FACTS_ENABLED || 'false' }} + BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }} + BINANCE_API_SECRET: ${{ secrets.BINANCE_API_SECRET }} + BINANCE_ACCOUNT_FACTS_BINDING_JSON: ${{ secrets.BINANCE_ACCOUNT_FACTS_BINDING_JSON }} + RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }} + BINANCE_ACCOUNT_FACTS_READER_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} + READER_PUBLIC_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_REF: ${{ github.ref }} + run: | + set -euo pipefail + uv run --no-sync python scripts/read_binance_account_facts.py \ + --direct-read \ + --output "$FACTS_DIR/account_facts.json" + - name: Recheck Runtime source before publishing + if: ${{ inputs.source_mode != 'direct_read' }} shell: bash env: SOURCE_RUN_ID: ${{ steps.preflight.outputs.source_run_id }} diff --git a/scripts/read_binance_account_facts.py b/scripts/read_binance_account_facts.py index 9c0b0d64..051d80ee 100644 --- a/scripts/read_binance_account_facts.py +++ b/scripts/read_binance_account_facts.py @@ -36,6 +36,7 @@ LEGACY_RUNTIME_WORKFLOW_SHA = "9cfcf0531d1ea176e6f26590cf15edbd31bd6567" APPROVED_APPLICATION_SHA = "8cb56617115fa45028e34d788e71884b6a303d77" RUNTIME_BRANCH = "runtime-production" +DIRECT_READ_BRANCH = "main" EXPECTED_REPORT_ARTIFACT_PREFIX = "binance-execution-report-" _GIT_SHA = re.compile(r"[0-9a-f]{40}\Z") _SHA256 = re.compile(r"[0-9a-f]{64}\Z") @@ -439,7 +440,9 @@ def verify_parent_run( def verify_current_source_from_env(env: Mapping[str, str]) -> None: mode = env.get("SOURCE_MODE", "legacy_terminal") - if mode == "same_parent": + if mode == "direct_read": + verify_direct_reader_source_from_env(env) + elif mode == "same_parent": try: attempt = int(env.get("GITHUB_RUN_ATTEMPT", "")) except ValueError: @@ -460,6 +463,19 @@ def verify_current_source_from_env(env: Mapping[str, str]) -> None: raise _stop("source_mode_invalid") +def verify_direct_reader_source_from_env(env: Mapping[str, str]) -> None: + """Verify the manual read-only entry is running from its pinned main source.""" + reader_revision = str(env.get("BINANCE_ACCOUNT_FACTS_READER_REVISION") or "") + if ( + env.get("GITHUB_REPOSITORY") != REPOSITORY + or env.get("GITHUB_REF") != f"refs/heads/{DIRECT_READ_BRANCH}" + or env.get("SOURCE_MODE") != "direct_read" + or not _GIT_SHA.fullmatch(reader_revision) + or env.get("READER_PUBLIC_REVISION") != reader_revision + ): + raise _stop("reader_source_mismatch") + + def verify_trigger_run( *, run_id: str, repository: str, token: str, api_url: str, expected_workflow_sha: str = LEGACY_RUNTIME_WORKFLOW_SHA, @@ -693,7 +709,7 @@ def _validate_execution_receipt_status(report: Mapping[str, Any]) -> None: raise _stop("strategy_submission_unclosed") -def _target_identity(raw: str) -> dict[str, Any]: +def _target_identity(raw: str, *, allow_dry_run_only: bool = False) -> dict[str, Any]: decoded = _json_no_duplicates(raw.encode("utf-8")) if not isinstance(decoded, Mapping): raise _stop("runtime_target_invalid") @@ -707,7 +723,7 @@ def _target_identity(raw: str) -> dict[str, Any]: raise _stop("runtime_target_invalid") value = target.to_dict() if ( - target.dry_run_only + (target.dry_run_only and not allow_dry_run_only) or not isinstance(value.get("account_scope"), str) or not value["account_scope"] or value["account_scope"] == "default" @@ -779,6 +795,19 @@ def _expected_scope(raw: str) -> str: return scope +def _bound_scope(raw_binding: str) -> str: + """Read the expected UID digest from the protected account-facts binding.""" + value = _json_no_duplicates(raw_binding.encode("utf-8")) + scope = ( + str(value.get("account_scope_sha256") or "").lower().removeprefix("sha256:") + if isinstance(value, Mapping) + else "" + ) + if not _SHA256.fullmatch(scope): + raise _stop("account_identity_unbound") + return scope + + def _authority_payload(env: Mapping[str, str]) -> Mapping[str, Any]: raw_text = str(env.get("BINANCE_RISK_AUTHORITY_JSON") or "") path_text = str(env.get("BINANCE_RISK_AUTHORITY_FILE") or "") @@ -868,9 +897,39 @@ def _provider_product_type_for_log(payload: Mapping[str, Any]) -> str: return value if type(value) is str and value in ("SPOT", "unknown") else "unknown" +def _collect_bound_account_facts( + *, binding: Mapping[str, Any], expected_scope: str, + reader_revision: str, env: Mapping[str, str], +) -> Mapping[str, Any]: + # Client construction is network-silent: python-binance ping is disabled. + # The capability wrapper exposes only the required signed read-only GETs. + try: + from binance.client import Client + raw_client = Client( + env["BINANCE_API_KEY"], env["BINANCE_API_SECRET"], + requests_params={"timeout": 15}, ping=False, + ) + except Exception: + raise _stop("client_initialization_failed") from None + client = ReadOnlyBinanceClient(raw_client) + started_at = datetime.now(timezone.utc) + payload = collect_account_facts( + client, + expected_account_scope_sha256=expected_scope, + target_id=str(binding["target_id"]), + reader_public_revision=reader_revision, + approved_application_revision=APPROVED_APPLICATION_SHA, + observed_started_at=started_at, + clock=lambda: datetime.now(timezone.utc), + ) + return validate_account_facts_payload(payload) + + def read_account_facts(*, report_path: Path, output_path: Path, env: Mapping[str, str]) -> str: if env.get("BINANCE_ACCOUNT_FACTS_ENABLED") != "true": raise _stop("disabled") + if env.get("SOURCE_MODE", "legacy_terminal") == "direct_read": + raise _stop("source_mode_invalid") if env.get("RUNTIME_TARGET_ENABLED") != "true" or env.get("BINANCE_DRY_RUN") != "false": raise _stop("runtime_not_live") if not env.get("BINANCE_API_KEY") or not env.get("BINANCE_API_SECRET"): @@ -891,35 +950,48 @@ def read_account_facts(*, report_path: Path, output_path: Path, env: Mapping[str validate_strategy_report(report, target) verify_current_source_from_env(env) - # Client construction is network-silent: python-binance ping is disabled. - # The capability wrapper exposes only the required signed read-only GETs. - try: - from binance.client import Client - raw_client = Client( - env["BINANCE_API_KEY"], env["BINANCE_API_SECRET"], - requests_params={"timeout": 15}, ping=False, - ) - except Exception: - raise _stop("client_initialization_failed") from None - client = ReadOnlyBinanceClient(raw_client) - started_at = datetime.now(timezone.utc) - payload = collect_account_facts( - client, - expected_account_scope_sha256=expected_scope, - target_id=str(binding["target_id"]), - reader_public_revision=reader_revision, - approved_application_revision=APPROVED_APPLICATION_SHA, - observed_started_at=started_at, - clock=lambda: datetime.now(timezone.utc), + validated_payload = _collect_bound_account_facts( + binding=binding, expected_scope=expected_scope, + reader_revision=reader_revision, env=env, ) verify_current_source_from_env(env) - validated_payload = validate_account_facts_payload(payload) + _write_private_json(output_path, validated_payload) + return _provider_product_type_for_log(validated_payload) + + +def read_account_facts_direct(*, output_path: Path, env: Mapping[str, str]) -> str: + """Read the existing bound account directly without Runtime or trade authority.""" + if env.get("BINANCE_ACCOUNT_FACTS_ENABLED") != "true": + raise _stop("disabled") + if not env.get("BINANCE_API_KEY") or not env.get("BINANCE_API_SECRET"): + raise _stop("credentials_missing") + verify_direct_reader_source_from_env(env) + target = _target_identity(env.get("RUNTIME_TARGET_JSON", ""), allow_dry_run_only=True) + reader_revision = str(env.get("BINANCE_ACCOUNT_FACTS_READER_REVISION") or "") + if not _GIT_SHA.fullmatch(reader_revision): + raise _stop("reader_revision_invalid") + raw_binding = env.get("BINANCE_ACCOUNT_FACTS_BINDING_JSON", "") + expected_scope = _bound_scope(raw_binding) + binding = _binding(raw_binding, target, expected_scope, reader_revision) + validated_payload = _collect_bound_account_facts( + binding=binding, expected_scope=expected_scope, + reader_revision=reader_revision, env=env, + ) + verify_direct_reader_source_from_env(env) _write_private_json(output_path, validated_payload) return _provider_product_type_for_log(validated_payload) def preflight(*, env: Mapping[str, str], output: Path) -> None: mode = env.get("SOURCE_MODE", "legacy_terminal") + if mode == "direct_read": + verify_direct_reader_source_from_env(env) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text( + f"source_mode=direct_read\nreader_revision={env['BINANCE_ACCOUNT_FACTS_READER_REVISION']}\n", + encoding="utf-8", + ) + return if mode == "same_parent": try: attempt = int(env.get("GITHUB_RUN_ATTEMPT", "")) @@ -952,18 +1024,33 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--preflight", action="store_true") parser.add_argument("--verify-current-source", action="store_true") + parser.add_argument("--direct-read", action="store_true") parser.add_argument("--report", type=Path) parser.add_argument("--output", type=Path) args = parser.parse_args(argv) try: if args.preflight: - preflight(env=os.environ, output=Path(os.getenv("GITHUB_OUTPUT") or "account-facts-preflight.json")) + preflight( + env=os.environ, + output=Path(os.getenv("GITHUB_OUTPUT") or "account-facts-preflight.json"), + ) print("account_facts_preflight=ready") return 0 if args.verify_current_source: verify_current_source_from_env(os.environ) print("account_facts_source_current=true") return 0 + if args.direct_read: + if args.report is not None or args.output is None: + raise _stop("arguments_invalid") + provider_product_type = read_account_facts_direct( + output_path=args.output, env=os.environ + ) + if type(provider_product_type) is not str or provider_product_type not in ("SPOT", "unknown"): + provider_product_type = "unknown" + print("account_facts_read=complete_for_scope") + print(f"account_facts_provider_product_type={provider_product_type}") + return 0 if args.report is None or args.output is None: raise _stop("arguments_invalid") provider_product_type = read_account_facts( diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 8d8e1b81..92d03ac1 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -364,8 +364,9 @@ def test_workflow_uses_fixed_protected_reader_revision_not_trigger_sha(): workflow = (Path(__file__).parents[1] / ".github" / "workflows" / "binance-account-facts.yml").read_text() checkout = workflow.index(" - name: Checkout trusted default-branch reader source") setup_uv = workflow.index(" - name: Set up uv") - preflight = workflow.index(" - name: Verify trusted checkout and exact Runtime source") + preflight = workflow.index(" - name: Verify trusted reader checkout and source mode") install = workflow.index("uv sync --frozen --no-dev") + preflight_env = workflow[preflight:install] assert checkout < setup_uv < preflight < install setup_uv_step = workflow[setup_uv:preflight] @@ -375,16 +376,192 @@ def test_workflow_uses_fixed_protected_reader_revision_not_trigger_sha(): assert workflow.count("astral-sh/setup-uv@") == 1 assert "ref: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION }}" in workflow - assert 'BINANCE_ACCOUNT_FACTS_READER_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || \'\' }}' in workflow + assert ( + 'BINANCE_ACCOUNT_FACTS_READER_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || \'\' }}' + in workflow + ) + assert preflight_env.count("BINANCE_ACCOUNT_FACTS_READER_REVISION:") == 1 assert "READER_PUBLIC_REVISION: ${{ vars.BINANCE_ACCOUNT_FACTS_READER_REVISION || '' }}" in workflow assert "ref: ${{ github.sha }}" not in workflow assert "git rev-parse HEAD" in workflow assert "workflow_call:" in workflow assert "workflow_run:" not in workflow + assert 'description: "Run the manual read-only account-facts read; repository gate must also be true."' in workflow assert "github.ref == 'refs/heads/runtime-production'" in workflow assert "timeout-minutes: 5" in workflow assert "continue-on-error: true" in workflow + direct = workflow.split(" - name: Read directly from the protected account binding", 1)[1] + direct = direct.split(" - name: Recheck Runtime source before publishing", 1)[0] + assert ( + "if: ${{ github.event_name == 'workflow_dispatch' && inputs.source_mode == 'direct_read' }}" + in direct + ) + assert "--direct-read" in direct + assert "BINANCE_ACCOUNT_FACTS_BINDING_JSON: ${{ secrets.BINANCE_ACCOUNT_FACTS_BINDING_JSON }}" in direct + assert "BINANCE_API_KEY: ${{ secrets.BINANCE_API_KEY }}" in direct + assert "BINANCE_API_SECRET: ${{ secrets.BINANCE_API_SECRET }}" in direct + for trading_gate in ( + "RUNTIME_TARGET_ENABLED", "BINANCE_DRY_RUN", "BINANCE_RISK_AUTHORITY_JSON", + "BINANCE_RUNTIME_RELEASE_SHA", "BINANCE_RECONCILIATION_EXPECTED_DIGESTS_JSON", + ): + assert trading_gate not in direct + assert "inputs.source_mode == 'direct_read'" in workflow + assert "github.ref == 'refs/heads/main'" in workflow + assert "options: [direct_read, legacy_terminal]" in workflow + assert "expected_ref=refs/heads/runtime-production" in workflow + assert "expected_ref=refs/heads/main" in workflow + assert "[ \"$SOURCE_MODE\" = \"direct_read\" ]" in workflow + assert "if: ${{ inputs.source_mode != 'direct_read' }}" in workflow + assert "environment: binance-runtime" in workflow + assert "group: binance-account-facts-readonly" in workflow + + +def _direct_read_binding_env(*, dry_run_only=True): + from scripts import read_binance_account_facts as reader + + expected_scope = _uid_hash() + target = { + "platform_id": "binance", + "strategy_profile": "synthetic-profile", + "dry_run_only": dry_run_only, + "deployment_selector": "synthetic-deployment", + "account_selector": ["synthetic-account"], + "account_scope": "synthetic-scope", + "service_name": "synthetic-service", + } + binding = { + "platform": "binance", + "account_key": "synthetic-key", + "account_scope": "synthetic-scope", + "target_name": "synthetic-target", + "service_name": "synthetic-service", + "deployment_selector": "synthetic-deployment", + "account_selector": "synthetic-account", + "target_id": "synthetic-target-id", + "account_scope_sha256": expected_scope, + "reader_revision": READER_SHA, + "approved_application_revision": APP_SHA, + "source_binding": { + "kind": "binance_readonly_scope_revision", + "id": build_source_binding_id( + account_scope_sha256=expected_scope, + reader_public_revision=READER_SHA, + approved_application_revision=APP_SHA, + ), + }, + } + env = { + "SOURCE_MODE": "direct_read", + "GITHUB_REPOSITORY": reader.REPOSITORY, + "GITHUB_REF": "refs/heads/main", + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "BINANCE_ACCOUNT_FACTS_READER_REVISION": READER_SHA, + "READER_PUBLIC_REVISION": READER_SHA, + "BINANCE_API_KEY": "synthetic-api-key", + "BINANCE_API_SECRET": "synthetic-api-secret", + "RUNTIME_TARGET_JSON": json.dumps(target), + "BINANCE_ACCOUNT_FACTS_BINDING_JSON": json.dumps(binding), + } + return env, target, binding + + +@pytest.mark.parametrize("dry_run_only", [True, False]) +def test_direct_read_uses_private_uid_and_app_binding_without_runtime_or_trade_gates( + monkeypatch, tmp_path, dry_run_only +): + import types + from scripts import read_binance_account_facts as reader + + env, _target, binding = _direct_read_binding_env(dry_run_only=dry_run_only) + calls = [] + + class SyntheticClient(FakeBinance): + def __init__(self, api_key, api_secret, *, requests_params, ping): + calls.append((api_key, api_secret, requests_params, ping)) + super().__init__() + + monkeypatch.setitem(sys.modules, "binance", types.ModuleType("binance")) + client_module = types.ModuleType("binance.client") + client_module.Client = SyntheticClient + monkeypatch.setitem(sys.modules, "binance.client", client_module) + def unexpected_github_request(*_args, **_kwargs): + raise AssertionError("GitHub API must not be used") + + monkeypatch.setattr(reader, "_api_json", unexpected_github_request) + + output = tmp_path / "facts.json" + result = reader.read_account_facts_direct(output_path=output, env=env) + + assert result == "SPOT" + assert calls == [("synthetic-api-key", "synthetic-api-secret", {"timeout": 15}, False)] + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["target_id"] == binding["target_id"] + assert payload["account_scope_sha256"] == _uid_hash() + assert payload["source_binding"]["id"] == binding["source_binding"]["id"] + assert "RUNTIME_TARGET_ENABLED" not in env + assert "BINANCE_DRY_RUN" not in env + assert "BINANCE_RISK_AUTHORITY_JSON" not in env + assert "BINANCE_RUNTIME_RELEASE_SHA" not in env + assert "BINANCE_RECONCILIATION_EXPECTED_DIGESTS_JSON" not in env + + +@pytest.mark.parametrize( + ("change", "reason"), + [ + ({"GITHUB_REF": "refs/heads/runtime-production"}, "reader_source_mismatch"), + ({"READER_PUBLIC_REVISION": "c" * 40}, "reader_source_mismatch"), + ({"BINANCE_ACCOUNT_FACTS_ENABLED": "false"}, "disabled"), + ], +) +def test_direct_read_still_requires_manual_main_reader_and_account_binding( + monkeypatch, tmp_path, change, reason +): + from scripts import read_binance_account_facts as reader + + env, _target, _binding = _direct_read_binding_env() + env.update(change) + with pytest.raises(reader.ReaderError, match=reason): + reader.read_account_facts_direct(output_path=tmp_path / "facts.json", env=env) + + +def test_direct_read_preflight_needs_only_pinned_reader_and_main_context(tmp_path): + from scripts import read_binance_account_facts as reader + + env, _target, _binding = _direct_read_binding_env() + output = tmp_path / "github-output.txt" + reader.preflight(env=env, output=output) + assert output.read_text(encoding="utf-8") == ( + "source_mode=direct_read\nreader_revision=" + READER_SHA + "\n" + ) + + +def test_direct_read_rejects_wrong_account_uid_before_earn_or_write(monkeypatch, tmp_path): + import types + from scripts import read_binance_account_facts as reader + + env, _target, _binding = _direct_read_binding_env() + calls = [] + + class WrongUidClient(FakeBinance): + def get_account(self): + calls.append("spot") + return {"uid": "654321", "balances": []} + + def get_simple_earn_flexible_product_position(self, **_kwargs): + calls.append("earn") + return {"rows": [], "total": 0} + + monkeypatch.setitem(sys.modules, "binance", types.ModuleType("binance")) + client_module = types.ModuleType("binance.client") + client_module.Client = lambda *_args, **_kwargs: WrongUidClient() + monkeypatch.setitem(sys.modules, "binance.client", client_module) + output = tmp_path / "facts.json" + with pytest.raises(AccountFactsUnavailable, match="account_identity_mismatch"): + reader.read_account_facts_direct(output_path=output, env=env) + assert calls == ["spot"] + assert not output.exists() + def _parent_run_api(url, _token): from urllib.parse import parse_qs, urlparse @@ -939,6 +1116,25 @@ def test_account_facts_cli_emits_only_closed_product_type( assert private_value not in output +def test_account_facts_cli_direct_read_uses_dedicated_argument(monkeypatch, capsys, tmp_path): + from scripts import read_binance_account_facts as reader + + calls = [] + monkeypatch.setattr( + reader, + "read_account_facts_direct", + lambda **kwargs: calls.append(kwargs) or "SPOT", + ) + output_path = tmp_path / "facts.json" + assert reader.main(["--direct-read", "--output", str(output_path)]) == 0 + output = capsys.readouterr().out + assert calls == [{"output_path": output_path, "env": reader.os.environ}] + assert output.splitlines() == [ + "account_facts_read=complete_for_scope", + "account_facts_provider_product_type=SPOT", + ] + + def test_account_facts_cli_failure_never_prints_product_type(monkeypatch, capsys, tmp_path): from scripts import read_binance_account_facts as reader From 6ccdd676cf6ccee45fdd3ffcaffec74dca86af6e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:12:07 +0800 Subject: [PATCH 35/47] fix: retain the protected branch for direct account reads Co-Authored-By: Codex --- .github/workflows/binance-account-facts.yml | 7 +++---- scripts/read_binance_account_facts.py | 4 ++-- tests/test_binance_account_facts.py | 14 +++++++------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.github/workflows/binance-account-facts.yml b/.github/workflows/binance-account-facts.yml index 15081f0a..e09572da 100644 --- a/.github/workflows/binance-account-facts.yml +++ b/.github/workflows/binance-account-facts.yml @@ -46,7 +46,7 @@ jobs: vars.BINANCE_ACCOUNT_FACTS_ENABLED == 'true' && ((github.event_name == 'workflow_dispatch' && github.workflow == 'Binance Account Facts' && inputs.enabled == true && inputs.source_mode == 'direct_read' && - github.ref == 'refs/heads/main') || + github.ref == 'refs/heads/runtime-production') || (github.ref == 'refs/heads/runtime-production' && ((inputs.enabled == true && inputs.source_mode == 'same_parent') || (github.workflow == 'Binance Account Facts' && github.event_name == 'workflow_dispatch' && @@ -80,9 +80,8 @@ jobs: run: | set -euo pipefail expected_ref=refs/heads/runtime-production - if [ "$SOURCE_MODE" = "direct_read" ]; then - expected_ref=refs/heads/main - elif [ "$SOURCE_MODE" != "same_parent" ] && [ "$SOURCE_MODE" != "legacy_terminal" ]; then + if [ "$SOURCE_MODE" != "direct_read" ] && \ + [ "$SOURCE_MODE" != "same_parent" ] && [ "$SOURCE_MODE" != "legacy_terminal" ]; then echo "account_facts_source_mode_invalid" >&2 exit 1 fi diff --git a/scripts/read_binance_account_facts.py b/scripts/read_binance_account_facts.py index 051d80ee..9591de6c 100644 --- a/scripts/read_binance_account_facts.py +++ b/scripts/read_binance_account_facts.py @@ -36,7 +36,7 @@ LEGACY_RUNTIME_WORKFLOW_SHA = "9cfcf0531d1ea176e6f26590cf15edbd31bd6567" APPROVED_APPLICATION_SHA = "8cb56617115fa45028e34d788e71884b6a303d77" RUNTIME_BRANCH = "runtime-production" -DIRECT_READ_BRANCH = "main" +DIRECT_READ_BRANCH = "runtime-production" EXPECTED_REPORT_ARTIFACT_PREFIX = "binance-execution-report-" _GIT_SHA = re.compile(r"[0-9a-f]{40}\Z") _SHA256 = re.compile(r"[0-9a-f]{64}\Z") @@ -464,7 +464,7 @@ def verify_current_source_from_env(env: Mapping[str, str]) -> None: def verify_direct_reader_source_from_env(env: Mapping[str, str]) -> None: - """Verify the manual read-only entry is running from its pinned main source.""" + """Verify the manual read-only entry runs from the protected production branch.""" reader_revision = str(env.get("BINANCE_ACCOUNT_FACTS_READER_REVISION") or "") if ( env.get("GITHUB_REPOSITORY") != REPOSITORY diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 92d03ac1..496b844e 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -407,11 +407,10 @@ def test_workflow_uses_fixed_protected_reader_revision_not_trigger_sha(): ): assert trading_gate not in direct assert "inputs.source_mode == 'direct_read'" in workflow - assert "github.ref == 'refs/heads/main'" in workflow + assert "github.ref == 'refs/heads/runtime-production'" in workflow assert "options: [direct_read, legacy_terminal]" in workflow assert "expected_ref=refs/heads/runtime-production" in workflow - assert "expected_ref=refs/heads/main" in workflow - assert "[ \"$SOURCE_MODE\" = \"direct_read\" ]" in workflow + assert '[ "$SOURCE_MODE" != "direct_read" ]' in workflow assert "if: ${{ inputs.source_mode != 'direct_read' }}" in workflow assert "environment: binance-runtime" in workflow assert "group: binance-account-facts-readonly" in workflow @@ -454,7 +453,7 @@ def _direct_read_binding_env(*, dry_run_only=True): env = { "SOURCE_MODE": "direct_read", "GITHUB_REPOSITORY": reader.REPOSITORY, - "GITHUB_REF": "refs/heads/main", + "GITHUB_REF": "refs/heads/runtime-production", "BINANCE_ACCOUNT_FACTS_ENABLED": "true", "BINANCE_ACCOUNT_FACTS_READER_REVISION": READER_SHA, "READER_PUBLIC_REVISION": READER_SHA, @@ -509,12 +508,12 @@ def unexpected_github_request(*_args, **_kwargs): @pytest.mark.parametrize( ("change", "reason"), [ - ({"GITHUB_REF": "refs/heads/runtime-production"}, "reader_source_mismatch"), + ({"GITHUB_REF": "refs/heads/main"}, "reader_source_mismatch"), ({"READER_PUBLIC_REVISION": "c" * 40}, "reader_source_mismatch"), ({"BINANCE_ACCOUNT_FACTS_ENABLED": "false"}, "disabled"), ], ) -def test_direct_read_still_requires_manual_main_reader_and_account_binding( +def test_direct_read_still_requires_manual_protected_reader_and_account_binding( monkeypatch, tmp_path, change, reason ): from scripts import read_binance_account_facts as reader @@ -525,10 +524,11 @@ def test_direct_read_still_requires_manual_main_reader_and_account_binding( reader.read_account_facts_direct(output_path=tmp_path / "facts.json", env=env) -def test_direct_read_preflight_needs_only_pinned_reader_and_main_context(tmp_path): +def test_direct_read_preflight_needs_only_pinned_reader_and_protected_context(tmp_path): from scripts import read_binance_account_facts as reader env, _target, _binding = _direct_read_binding_env() + assert env["GITHUB_REF"] == "refs/heads/runtime-production" output = tmp_path / "github-output.txt" reader.preflight(env=env, output=output) assert output.read_text(encoding="utf-8") == ( From c986fc75bf323478e0b354bd7ad0f38ac9d0162a Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 8 Oct 2026 16:48:09 +0800 Subject: [PATCH 36/47] Schedule gated daily read-only Binance account facts refresh Co-Authored-By: Codex --- .../refresh-binance-account-facts.yml | 90 ++++++++++ README.md | 6 + README.zh-CN.md | 6 + ..._binance_account_facts_refresh_workflow.py | 161 ++++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 .github/workflows/refresh-binance-account-facts.yml create mode 100644 tests/test_binance_account_facts_refresh_workflow.py diff --git a/.github/workflows/refresh-binance-account-facts.yml b/.github/workflows/refresh-binance-account-facts.yml new file mode 100644 index 00000000..c0c2cc44 --- /dev/null +++ b/.github/workflows/refresh-binance-account-facts.yml @@ -0,0 +1,90 @@ +name: Refresh Binance Account Facts + +"on": + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + inputs: + enabled: + description: "Run one read-only account-facts refresh; the repository gate must also be enabled." + required: true + type: boolean + default: false + +permissions: + contents: read + +jobs: + dispatch-account-facts-read: + if: >- + github.repository == 'QuantStrategyLab/BinancePlatform' && + github.ref == 'refs/heads/main' && + vars.BINANCE_ACCOUNT_FACTS_REFRESH_ENABLED == 'true' && + (github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && inputs.enabled == true)) + runs-on: ubuntu-latest + timeout-minutes: 2 + permissions: + actions: write + contents: read + steps: + - name: Dispatch the fixed read-only account-facts workflow + shell: python + env: + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + import json + import os + import sys + from urllib.error import HTTPError, URLError + from urllib.request import HTTPRedirectHandler, Request, build_opener + + api_url = os.environ.get("GITHUB_API_URL", "").rstrip("/") + token = os.environ.get("GITHUB_TOKEN", "") + if not api_url or not token: + print("account_facts_dispatch_not_configured", file=sys.stderr) + raise SystemExit(1) + + url = ( + f"{api_url}/repos/QuantStrategyLab/BinancePlatform/actions/" + "workflows/binance-account-facts.yml/dispatches" + ) + payload = { + "ref": "runtime-production", + "inputs": {"enabled": True, "source_mode": "direct_read"}, + } + request = Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + method="POST", + ) + + class RejectRedirects(HTTPRedirectHandler): + def redirect_request(self, _request, _file, _code, _message, _headers, _url): + return None + + opener = build_opener(RejectRedirects) + + try: + with opener.open(request, timeout=30) as response: + if response.status not in (200, 204): + print("account_facts_dispatch_outcome_unknown", file=sys.stderr) + raise SystemExit(1) + except HTTPError as error: + if 400 <= error.code < 500: + print("account_facts_dispatch_rejected", file=sys.stderr) + else: + print("account_facts_dispatch_outcome_unknown", file=sys.stderr) + raise SystemExit(1) + except (URLError, TimeoutError, OSError): + print("account_facts_dispatch_outcome_unknown", file=sys.stderr) + raise SystemExit(1) + + print("account_facts_dispatch=accepted") diff --git a/README.md b/README.md index bfa9a03e..7a832378 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ BinancePlatform is QuantStrategyLab's execution runtime for Binance crypto tradi - Must keep credentials in GitHub Secrets, cloud secret stores, or the broker-specific secret system, never in Git. - Should start with dry-run or paper mode before any live order path is enabled. +### Read-only Binance account-facts refresh + +The `Refresh Binance Account Facts` workflow can dispatch the existing `Binance Account Facts` workflow once per day at 06:17 UTC. It is restricted to this repository's `main` branch and is disabled unless the repository variable `BINANCE_ACCOUNT_FACTS_REFRESH_ENABLED` is exactly `true`; an absent variable leaves it disabled. The dispatcher uses only its short-lived GitHub token to request the fixed `runtime-production` `direct_read` workflow input. It does not receive broker credentials, enable Runtime, or dispatch the trading workflow. The target workflow retains its own `BINANCE_ACCOUNT_FACTS_ENABLED` gate and protected `binance-runtime` environment. + +QSL treats account-facts reports as stale after 36 hours. A daily refresh is intended to keep read-only balances current; a failed or skipped target run does not make old data current. The default configuration remains off; production activation is controlled by the dedicated repository gate and requires explicit authorization. + ## Direct vs snapshot-backed profiles Direct runtime profiles can usually run from market history or portfolio state. Snapshot-backed profiles need a current artifact bundle from the matching live-pool pipeline before this platform should execute them. The platform should not invent strategy eligibility; it should consume the status and artifacts published by the strategy and live-pool repositories. diff --git a/README.zh-CN.md b/README.zh-CN.md index ac1a1946..85674585 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,6 +21,12 @@ BinancePlatform 是 QuantStrategyLab 的 Binance 加密货币执行 runtime: - 凭据必须放在 GitHub Secrets、云密钥系统或券商专用密钥系统中,不能提交到 Git。 - 任何 live 下单路径启用前,都应先从 dry-run 或 paper mode 开始。 +### Binance 账户事实只读取数 + +`Refresh Binance Account Facts` workflow 可每天 UTC 06:17 触发一次现有的 `Binance Account Facts` workflow。触发器只接受本仓库 `main` 分支,并要求仓库变量 `BINANCE_ACCOUNT_FACTS_REFRESH_ENABLED` 的值严格为 `true`;变量未设置时保持关闭。dispatcher 只用短时 GitHub token 请求固定的 `runtime-production` 与 `direct_read` 输入,不接收券商凭据、不启用 Runtime,也不触发交易 workflow。目标 workflow 仍独立检查 `BINANCE_ACCOUNT_FACTS_ENABLED`,并使用受保护的 `binance-runtime` environment。 + +QSL 会在账户事实报告超过 36 小时后将其视为过期。每日刷新用于保持只读余额及时;目标任务失败或被跳过不会让旧数据变新。默认配置保持关闭;生产启用由专用仓库开关控制,并需要明确授权。 + ## 普通 profile 与 snapshot-backed profile 普通 runtime profile 通常可以直接基于 market history 或 portfolio state 执行。Snapshot-backed profile 需要先从对应 live-pool pipeline 获取当前 artifact bundle,平台才应该执行。平台不应该自行判断策略资格,而应消费策略仓和 live-pool 仓发布的状态与产物。 diff --git a/tests/test_binance_account_facts_refresh_workflow.py b/tests/test_binance_account_facts_refresh_workflow.py new file mode 100644 index 00000000..059fe38f --- /dev/null +++ b/tests/test_binance_account_facts_refresh_workflow.py @@ -0,0 +1,161 @@ +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +import re +import subprocess +import sys +import threading +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOW = ROOT / ".github/workflows/refresh-binance-account-facts.yml" + + +def _workflow_text() -> str: + return WORKFLOW.read_text(encoding="utf-8") + + +def _dispatch_script() -> str: + workflow = _workflow_text() + match = re.search( + r"(?ms)^ run: \|\n(?P(?:^ .*\n)+)", workflow + ) + assert match, "dispatcher must keep one inline, independently testable script" + return textwrap.dedent(match.group("body")) + + +def _run_against_mock(status: int) -> tuple[subprocess.CompletedProcess[str], list[dict]]: + requests: list[dict] = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", "0")) + requests.append( + { + "path": self.path, + "authorization": self.headers.get("Authorization"), + "body": json.loads(self.rfile.read(length)), + } + ) + self.send_response(status) + if status == 302: + self.send_header( + "Location", f"http://127.0.0.1:{self.server.server_port}/redirect" + ) + self.end_headers() + self.wfile.write(b"mock-error-response-body") + + def do_GET(self) -> None: + requests.append( + {"path": self.path, "authorization": self.headers.get("Authorization")} + ) + self.send_response(204) + self.end_headers() + + def log_message(self, _format: str, *_args: object) -> None: + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + env = os.environ.copy() + env.update( + { + "GITHUB_API_URL": f"http://127.0.0.1:{server.server_port}/api/v3", + "GITHUB_TOKEN": "synthetic-dispatch-token", + } + ) + result = subprocess.run( + [sys.executable, "-c", _dispatch_script()], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + timeout=5, + ) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + return result, requests + + +class BinanceAccountFactsRefreshWorkflowTests(unittest.TestCase): + def test_dispatch_is_gated_fixed_and_minimally_privileged(self) -> None: + workflow = _workflow_text() + gate = workflow.split(" if: >-\n", 1)[1].split(" runs-on:", 1)[0] + + assert 'cron: "17 6 * * *"' in workflow + assert "default: false" in workflow + assert "type: boolean" in workflow + assert "inputs.enabled == true" in gate + assert "vars.BINANCE_ACCOUNT_FACTS_REFRESH_ENABLED == 'true'" in gate + assert "github.repository == 'QuantStrategyLab/BinancePlatform'" in gate + assert "github.ref == 'refs/heads/main'" in gate + assert "actions: write" in workflow + assert "contents: read" in workflow + assert "contents: write" not in workflow + assert "id-token:" not in workflow + assert "BINANCE_API_KEY" not in workflow + assert "BINANCE_API_SECRET" not in workflow + assert "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN" not in workflow + assert "RUNTIME_TARGET_ENABLED" not in workflow + assert "BINANCE_DRY_RUN" not in workflow + assert "source_mode:" not in workflow + assert "workflow:" not in workflow.split(" inputs:", 1)[1].split("permissions:", 1)[0] + assert "/repos/QuantStrategyLab/BinancePlatform/actions/" in workflow + assert "workflows/binance-account-facts.yml/dispatches" in workflow + assert '"ref": "runtime-production"' in workflow + assert '"source_mode": "direct_read"' in workflow + assert "while " not in workflow + + def test_dispatch_posts_only_fixed_target_once_and_accepts_success_codes(self) -> None: + for status in (200, 204): + with self.subTest(status=status): + result, requests = _run_against_mock(status) + + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "account_facts_dispatch=accepted" + assert result.stderr == "" + assert requests == [ + { + "path": ( + "/api/v3/repos/QuantStrategyLab/BinancePlatform/actions/" + "workflows/binance-account-facts.yml/dispatches" + ), + "authorization": "Bearer synthetic-dispatch-token", + "body": { + "ref": "runtime-production", + "inputs": { + "enabled": True, + "source_mode": "direct_read", + }, + }, + } + ] + + def test_ambiguous_dispatch_failure_is_redacted_and_never_retried(self) -> None: + result, requests = _run_against_mock(500) + + assert result.returncode != 0 + assert result.stdout == "" + assert result.stderr.strip() == "account_facts_dispatch_outcome_unknown" + assert "synthetic-dispatch-token" not in result.stdout + result.stderr + assert len(requests) == 1 + + def test_redirect_is_not_followed_or_disclosed(self) -> None: + result, requests = _run_against_mock(302) + + assert result.returncode != 0 + assert result.stdout == "" + assert result.stderr.strip() == "account_facts_dispatch_outcome_unknown" + assert "synthetic-dispatch-token" not in result.stdout + result.stderr + assert "mock-error-response-body" not in result.stdout + result.stderr + assert "127.0.0.1" not in result.stdout + result.stderr + assert len(requests) == 1 + assert requests[0]["path"].endswith("/binance-account-facts.yml/dispatches") From 5b1079af6fa924ee7f2ab0817c43a01c9bfe1d44 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:33:32 +0800 Subject: [PATCH 37/47] fix: preserve valid deployment observations independently of alerts (#355) Co-authored-by: Codex --- scripts/execution_report_heartbeat.py | 54 ++++++++++++++----- .../runtime_target_lifecycle_monitoring.py | 36 +++++++++++-- tests/test_execution_report_heartbeat.py | 46 +++++++++++++--- ...est_runtime_target_lifecycle_monitoring.py | 47 +++++++++++++++- 4 files changed, 160 insertions(+), 23 deletions(-) diff --git a/scripts/execution_report_heartbeat.py b/scripts/execution_report_heartbeat.py index ed38db89..aab87d38 100644 --- a/scripts/execution_report_heartbeat.py +++ b/scripts/execution_report_heartbeat.py @@ -143,16 +143,23 @@ def _operational_blocker(payload: dict[str, Any]) -> str | None: return value if isinstance(value, str) and value in _OPERATIONAL_BLOCKERS else None -def _accepted_payload(payload: dict[str, Any]) -> tuple[bool, str]: +def _identity_error(payload: dict[str, Any]) -> str | None: expected_platform = (os.environ.get("RUNTIME_HEARTBEAT_REPORT_PLATFORM") or "binance").strip().lower() expected_strategy = (os.environ.get("RUNTIME_HEARTBEAT_STRATEGY_PROFILE") or "").strip() expected_service = (os.environ.get("RUNTIME_HEARTBEAT_SERVICE_NAME") or "").strip() if str(payload.get("platform") or "").strip().lower() != expected_platform: - return False, "platform does not match" + return "platform does not match" if expected_strategy and _payload_strategy(payload) != expected_strategy: - return False, "strategy profile does not match" + return "strategy profile does not match" if expected_service and _payload_service_name(payload) != expected_service: - return False, "service name does not match" + return "service name does not match" + return None + + +def _accepted_payload(payload: dict[str, Any]) -> tuple[bool, str]: + identity_error = _identity_error(payload) + if identity_error is not None: + return False, identity_error if _operational_blocker(payload) is not None: return False, _OPERATIONAL_BLOCKER_REASON errors = payload.get("errors") @@ -168,8 +175,15 @@ def _accepted_payload(payload: dict[str, Any]) -> tuple[bool, str]: return True, f"status={status}" -def _deployment_observation(payload: dict[str, Any], *, now: dt.datetime) -> dict[str, Any] | None: - """Project the last run, never the hosted monitor's current environment.""" +def _deployment_observation( + payload: dict[str, Any], + *, + now: dt.datetime, + min_observed_at: dt.datetime | None = None, +) -> dict[str, Any] | None: + """Project recent, identity-matched runtime metadata, never monitor config.""" + if _identity_error(payload) is not None: + return None target = payload.get("runtime_target") if not isinstance(target, dict) or target.get("platform_id") != "binance": return None @@ -184,6 +198,9 @@ def _deployment_observation(payload: dict[str, Any], *, now: dt.datetime) -> dic return None if observed.tzinfo is None or observed > now: return None + observed = observed.astimezone(dt.timezone.utc) + if min_observed_at is not None and observed < min_observed_at.astimezone(dt.timezone.utc): + return None mode = target.get("execution_mode") dry_run = payload.get("dry_run") @@ -191,7 +208,8 @@ def _deployment_observation(payload: dict[str, Any], *, now: dt.datetime) -> dic mode = ("dry_run" if dry_run else "live") if isinstance(dry_run, bool) else None elif (mode == "live" and dry_run is True) or (mode != "live" and dry_run is False): mode = None - permitted = payload.get("standard_execution_permitted") + if mode not in {"live", "paper", "dry_run"}: + return None scheduler_state = payload.get("scheduler_state") scheduler_projection = { "enabled": "enabled", @@ -199,11 +217,14 @@ def _deployment_observation(payload: dict[str, Any], *, now: dt.datetime) -> dic "unknown": "unknown", }.get(scheduler_state, "unknown") if isinstance(scheduler_state, str) else "unknown" return { - "runtime_enabled": permitted if isinstance(permitted, bool) else None, + # standard_execution_permitted is the operator switch AND live- + # continuity permission. True proves the source switch was enabled; + # false cannot distinguish a disabled switch from a continuity hold. + "runtime_enabled": True if payload.get("standard_execution_permitted") is True else None, "scheduler_state": scheduler_projection, "strategy_profile": profile, "execution_mode": mode, - "observed_at": observed.astimezone(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), + "observed_at": observed.isoformat(timespec="seconds").replace("+00:00", "Z"), } @@ -243,6 +264,7 @@ def assess_execution_report_heartbeat(now: dt.datetime | None = None) -> dict[st } recent = [entry for entry in entries if (_entry_updated_at(entry) or current) >= since] + latest_deployment: dict[str, Any] | None = None for entry in sorted(recent, key=lambda item: _entry_updated_at(item) or dt.datetime.min.replace(tzinfo=dt.timezone.utc), reverse=True)[:10]: uri = _entry_uri(entry) if not uri: @@ -250,10 +272,13 @@ def assess_execution_report_heartbeat(now: dt.datetime | None = None) -> dict[st payload = _read_report(uri) if payload is None: continue + deployment = _deployment_observation(payload, now=current, min_observed_at=since) + if deployment is not None and latest_deployment is None: + latest_deployment = deployment accepted, reason = _accepted_payload(payload) if not accepted and reason == _OPERATIONAL_BLOCKER_REASON: blocker = _operational_blocker(payload) - return { + assessment = { "schema": _SCHEMA, "observed_at": current.isoformat().replace("+00:00", "Z"), "status": "alert", @@ -262,6 +287,9 @@ def assess_execution_report_heartbeat(now: dt.datetime | None = None) -> dict[st "report_updated_at": (_entry_updated_at(entry) or current).isoformat().replace("+00:00", "Z"), "reports_returned": len(entries), } + if deployment is not None: + assessment["deployment"] = deployment + return assessment if accepted: assessment = { "schema": _SCHEMA, @@ -271,12 +299,11 @@ def assess_execution_report_heartbeat(now: dt.datetime | None = None) -> dict[st "name": name, "report_updated_at": (_entry_updated_at(entry) or current).isoformat().replace("+00:00", "Z"), } - deployment = _deployment_observation(payload, now=current) if deployment is not None: assessment["deployment"] = deployment return assessment - return { + assessment = { "schema": _SCHEMA, "observed_at": current.isoformat().replace("+00:00", "Z"), "status": "alert", @@ -284,6 +311,9 @@ def assess_execution_report_heartbeat(now: dt.datetime | None = None) -> dict[st "name": name, "reports_returned": len(entries), } + if latest_deployment is not None: + assessment["deployment"] = latest_deployment + return assessment def _write_assessment(assessment: dict[str, Any]) -> None: diff --git a/scripts/runtime_target_lifecycle_monitoring.py b/scripts/runtime_target_lifecycle_monitoring.py index 519c508e..62e623c2 100644 --- a/scripts/runtime_target_lifecycle_monitoring.py +++ b/scripts/runtime_target_lifecycle_monitoring.py @@ -5,9 +5,12 @@ import json import os +import re +from datetime import datetime _CHECKS = frozenset({"pass", "attention", "not_due", "not_applicable", "unavailable"}) +_DEPLOYMENT_FIELDS = ("runtime_enabled", "scheduler_state", "strategy_profile", "execution_mode", "observed_at") def _read_assessment(path: str | None) -> dict: @@ -41,6 +44,33 @@ def _execution_check(status: str) -> str: }.get(status, "unavailable") +def _deployment_json(value: object) -> str | None: + if not isinstance(value, dict) or not all(field in value for field in _DEPLOYMENT_FIELDS): + return None + runtime_enabled = value.get("runtime_enabled") + profile = value.get("strategy_profile") + scheduler_state = value.get("scheduler_state") + execution_mode = value.get("execution_mode") + observed_at = value.get("observed_at") + if runtime_enabled is False or (runtime_enabled is not None and type(runtime_enabled) is not bool): + return None + if not isinstance(scheduler_state, str) or scheduler_state not in {"enabled", "paused", "unknown"}: + return None + if not isinstance(profile, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._=-]{0,127}", profile): + return None + if not isinstance(execution_mode, str) or execution_mode not in {"live", "paper", "dry_run"}: + return None + if not isinstance(observed_at, str): + return None + try: + parsed = datetime.fromisoformat(observed_at.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return json.dumps({key: value[key] for key in _DEPLOYMENT_FIELDS}, sort_keys=True) + + def resolve_monitoring( *, configured_state: str, @@ -78,9 +108,9 @@ def main() -> int: execution_status=str(execution.get("status") or "unavailable"), ) deployment = execution.get("deployment") - fields = ("runtime_enabled", "scheduler_state", "strategy_profile", "execution_mode", "observed_at") - if execution.get("status") == "healthy" and isinstance(deployment, dict) and all(key in deployment for key in fields): - values["deployment_json"] = json.dumps({key: deployment[key] for key in fields}, sort_keys=True) + deployment_json = _deployment_json(deployment) + if deployment_json is not None: + values["deployment_json"] = deployment_json output = os.environ.get("GITHUB_OUTPUT") if output: with open(output, "a", encoding="utf-8") as handle: diff --git a/tests/test_execution_report_heartbeat.py b/tests/test_execution_report_heartbeat.py index fed0072b..06ae06a4 100644 --- a/tests/test_execution_report_heartbeat.py +++ b/tests/test_execution_report_heartbeat.py @@ -31,7 +31,7 @@ def _observed_report(): return { **_report(), "started_at": "2026-08-30T10:00:00.123456+08:00", - "standard_execution_permitted": False, + "standard_execution_permitted": True, "dry_run": True, "scheduler_state": "enabled", "runtime_target": { @@ -92,8 +92,8 @@ def test_state_owner_busy_is_an_execution_alert(self) -> None: heartbeat, "_read_report", side_effect=[ - _report(execution_blocked_reason="state_owner_busy"), - _report(), + {**_observed_report(), "execution_blocked_reason": "state_owner_busy"}, + _report(), ], ), ): @@ -101,6 +101,8 @@ def test_state_owner_busy_is_an_execution_alert(self) -> None: self.assertEqual(result["status"], "alert") self.assertEqual(result["reason"], "execution_blocked:state_owner_busy") + self.assertEqual(result["deployment"]["runtime_enabled"], True) + self.assertEqual(result["deployment"]["observed_at"], "2026-08-30T02:00:00Z") def test_non_operational_execution_blockers_remain_accepted(self) -> None: for status, blocked_reason in ( @@ -143,7 +145,7 @@ def test_observation_uses_report_permission_and_original_run_time(self) -> None: result = heartbeat.assess_execution_report_heartbeat(now) self.assertEqual(result["deployment"], { - "runtime_enabled": False, + "runtime_enabled": True, "scheduler_state": "enabled", "strategy_profile": "crypto_live_pool_rotation", "execution_mode": "dry_run", @@ -185,13 +187,43 @@ def test_missing_invalid_or_future_run_time_omits_observation(self) -> None: def test_permission_is_strict_boolean_and_mode_does_not_use_environment(self) -> None: now = dt.datetime(2026, 8, 30, 3, tzinfo=dt.timezone.utc) os.environ["BINANCE_DRY_RUN"] = "false" - for permission in (None, "true", 1): + for permission in (False, None, "true", 1): with self.subTest(permission=permission): report = {**_observed_report(), "standard_execution_permitted": permission} observed = heartbeat._deployment_observation(report, now=now) self.assertIsNone(observed["runtime_enabled"]) self.assertEqual(observed["execution_mode"], "dry_run") + def test_deployment_observation_requires_recent_exact_identity(self) -> None: + now = dt.datetime(2026, 8, 30, 3, tzinfo=dt.timezone.utc) + since = dt.datetime(2026, 8, 30, 0, 30, tzinfo=dt.timezone.utc) + for mutate in ( + lambda report: report.update(platform="schwab"), + lambda report: report.update(service_name="other-service"), + lambda report: report.update(strategy_profile="other-profile"), + lambda report: report.update(started_at="2026-08-29T23:00:00Z"), + lambda report: report.update(started_at="2026-08-30T04:00:00Z"), + ): + with self.subTest(mutate=mutate): + report = _observed_report() + mutate(report) + self.assertIsNone( + heartbeat._deployment_observation(report, now=now, min_observed_at=since) + ) + + def test_attention_report_can_carry_deployment_without_becoming_healthy(self) -> None: + now = dt.datetime(2026, 8, 30, 3, tzinfo=dt.timezone.utc) + payload = {**_observed_report(), "status": "error", "errors": ["synthetic"]} + with ( + patch.object(heartbeat, "_list_reports", return_value=([_entry("2026-08-30T02:59:00Z")], "gs://reports")), + patch.object(heartbeat, "_read_report", return_value=payload), + ): + result = heartbeat.assess_execution_report_heartbeat(now) + + self.assertEqual(result["status"], "alert") + self.assertEqual(result["reason"], "no_recent_accepted_execution_report") + self.assertEqual(result["deployment"]["runtime_enabled"], True) + def test_report_mode_fallback_and_conflict_remain_bounded(self) -> None: now = dt.datetime(2026, 8, 30, 3, tzinfo=dt.timezone.utc) report = _observed_report() @@ -199,10 +231,10 @@ def test_report_mode_fallback_and_conflict_remain_bounded(self) -> None: self.assertEqual(heartbeat._deployment_observation(report, now=now)["execution_mode"], "dry_run") report["runtime_target"]["execution_mode"] = {} report["dry_run"] = None - self.assertIsNone(heartbeat._deployment_observation(report, now=now)["execution_mode"]) + self.assertIsNone(heartbeat._deployment_observation(report, now=now)) report["dry_run"] = True report["runtime_target"]["execution_mode"] = "live" - self.assertIsNone(heartbeat._deployment_observation(report, now=now)["execution_mode"]) + self.assertIsNone(heartbeat._deployment_observation(report, now=now)) report["runtime_target"]["strategy_profile"] = "other_profile" self.assertIsNone(heartbeat._deployment_observation(report, now=now)) diff --git a/tests/test_runtime_target_lifecycle_monitoring.py b/tests/test_runtime_target_lifecycle_monitoring.py index b978c2aa..add6ab44 100644 --- a/tests/test_runtime_target_lifecycle_monitoring.py +++ b/tests/test_runtime_target_lifecycle_monitoring.py @@ -12,7 +12,7 @@ class RuntimeTargetLifecycleMonitoringTests(unittest.TestCase): def test_report_observation_is_forwarded_without_changing_its_time(self): deployment = { - "runtime_enabled": False, + "runtime_enabled": None, "scheduler_state": "unknown", "strategy_profile": "crypto_live_pool_rotation", "execution_mode": "dry_run", @@ -32,6 +32,51 @@ def test_report_observation_is_forwarded_without_changing_its_time(self): values = dict(line.split("=", 1) for line in output.read_text().splitlines()) self.assertEqual(json.loads(values["deployment_json"]), deployment) + def test_alert_does_not_hide_valid_report_deployment(self): + deployment = { + "runtime_enabled": True, + "scheduler_state": "enabled", + "strategy_profile": "crypto_live_pool_rotation", + "execution_mode": "dry_run", + "observed_at": "2026-08-30T02:00:00Z", + } + with tempfile.TemporaryDirectory() as directory: + assessment = Path(directory) / "assessment.json" + output = Path(directory) / "output.txt" + assessment.write_text(json.dumps({"status": "alert", "deployment": deployment}), encoding="utf-8") + with patch.dict(os.environ, { + "CONFIGURED_STATE": "enabled", + "CONFIGURATION_GUARD": "pass", + "EXECUTION_HEARTBEAT_PATH": str(assessment), + "GITHUB_OUTPUT": str(output), + }, clear=True): + self.assertEqual(monitoring.main(), 0) + values = dict(line.split("=", 1) for line in output.read_text().splitlines()) + self.assertEqual(json.loads(values["deployment_json"]), deployment) + self.assertEqual(values["execution_heartbeat"], "attention") + + def test_malformed_deployment_is_not_forwarded(self): + invalid_deployments = ( + {"runtime_enabled": "true"}, + {"runtime_enabled": False, "scheduler_state": "enabled", "strategy_profile": "x", "execution_mode": "dry_run", "observed_at": "2026-08-30T02:00:00Z"}, + {"runtime_enabled": None, "scheduler_state": [], "strategy_profile": "x", "execution_mode": "dry_run", "observed_at": "2026-08-30T02:00:00Z"}, + {"runtime_enabled": None, "scheduler_state": "unknown", "strategy_profile": "x", "execution_mode": "unknown", "observed_at": "2026-08-30T02:00:00Z"}, + {"runtime_enabled": None, "scheduler_state": "unknown", "strategy_profile": "x", "execution_mode": "dry_run", "observed_at": "2026-08-30T02:00:00"}, + ) + for deployment in invalid_deployments: + with self.subTest(deployment=deployment), tempfile.TemporaryDirectory() as directory: + assessment = Path(directory) / "assessment.json" + output = Path(directory) / "output.txt" + assessment.write_text(json.dumps({"status": "alert", "deployment": deployment}), encoding="utf-8") + with patch.dict(os.environ, { + "CONFIGURED_STATE": "enabled", + "CONFIGURATION_GUARD": "pass", + "EXECUTION_HEARTBEAT_PATH": str(assessment), + "GITHUB_OUTPUT": str(output), + }, clear=True): + self.assertEqual(monitoring.main(), 0) + self.assertNotIn("deployment_json", output.read_text()) + def test_missing_report_does_not_synthesize_environment_observation(self): with tempfile.TemporaryDirectory() as directory: output = Path(directory) / "output.txt" From f1b54dbfb5d45a25fe2925bb69d8e241a37a84c5 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:02:39 +0800 Subject: [PATCH 38/47] fix: bound independent runtime scheduler diagnostics Co-Authored-By: Codex --- .../runner-scheduler-diagnostics.yml | 94 ++----------------- scripts/runtime_scheduler_probe.py | 51 ++++++++++ tests/test_runtime_scheduler_observation.py | 16 ++++ tests/test_runtime_scheduler_probe.py | 63 +++++++++++++ 4 files changed, 139 insertions(+), 85 deletions(-) create mode 100644 scripts/runtime_scheduler_probe.py create mode 100644 tests/test_runtime_scheduler_probe.py diff --git a/.github/workflows/runner-scheduler-diagnostics.yml b/.github/workflows/runner-scheduler-diagnostics.yml index 0a79258a..46f9757b 100644 --- a/.github/workflows/runner-scheduler-diagnostics.yml +++ b/.github/workflows/runner-scheduler-diagnostics.yml @@ -11,91 +11,15 @@ concurrency: cancel-in-progress: false jobs: - inspect: + observe: + if: ${{ github.ref == 'refs/heads/main' }} runs-on: self-hosted - timeout-minutes: 10 + timeout-minutes: 3 steps: - - name: Inspect scheduler configuration - shell: bash - run: | - set -euo pipefail + - name: Checkout reviewed main source + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false - redact() { - sed -E \ - -e 's/github_pat_[A-Za-z0-9_]+/[REDACTED_GITHUB_TOKEN]/g' \ - -e 's/gh[pousr]_[A-Za-z0-9_]+/[REDACTED_GITHUB_TOKEN]/g' \ - -e 's/(Authorization:[[:space:]]*Bearer[[:space:]]+)[A-Za-z0-9_.-]+/\1[REDACTED]/g' \ - -e 's/(x-access-token:)[^@[:space:]]+/\1[REDACTED]/g' \ - -e 's/((TOKEN|SECRET|KEY|PASSWORD)[A-Z0-9_]*[[:space:]]*=[[:space:]]*)[^[:space:]]+/\1[REDACTED]/g' - } - - section() { - printf '\n===== %s =====\n' "$1" - } - - section "host" - { - date -u - hostname - id - pwd - uname -a - } | redact - - section "ubuntu crontab" - crontab -l 2>&1 | redact || true - - section "root crontab" - sudo -n crontab -l 2>&1 | redact || true - - section "systemd timers" - systemctl list-timers --all 2>&1 | redact || true - - section "matching systemd units" - systemctl list-units --all 2>&1 | grep -Ei 'binance|quant|github|dispatch|runtime' | redact || true - - section "cron and systemd files" - find /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/systemd/system -maxdepth 2 -type f -print 2>/dev/null \ - | sort \ - | grep -Ei 'binance|quant|github|dispatch|runtime|cron|timer|service' \ - | redact || true - - section "candidate dispatch files" - find /home/ubuntu /usr/local/bin /opt -maxdepth 6 \ - \( -path '*/.git' -o -path '*/_work' -o -path '*/node_modules' \) -prune -o \ - -type f \ - \( -name '*.sh' -o -name '*.py' -o -name '*.service' -o -name '*.timer' -o -name '*.env' -o -name 'crontab' \) \ - -print 2>/dev/null \ - | sort \ - | grep -Ei 'binance|quant|github|dispatch|runtime|cron' \ - | redact || true - - section "dispatch content matches" - find /home/ubuntu /usr/local/bin /opt /etc/cron.d /etc/systemd/system -maxdepth 8 \ - \( -path '*/.git' -o -path '*/_work' -o -path '*/node_modules' \) -prune -o \ - -type f \ - \( -name '*.sh' -o -name '*.py' -o -name '*.service' -o -name '*.timer' -o -name '*.env' -o -name '*.conf' -o -name 'crontab' \) \ - -print 2>/dev/null \ - | while IFS= read -r file; do - if grep -IlE 'actions/workflows/main\.yml/dispatches|workflow_dispatch|QuantStrategyLab/BinancePlatform|binance-quant|binancequant' "$file" >/dev/null 2>&1; then - printf '%s\n' "$file" - fi - done \ - | sort \ - | head -200 \ - | redact || true - - section "dispatch script redacted" - if [ -f /home/ubuntu/binance-quant/ops/dispatch-runtime.sh ]; then - sed -n '1,240p' /home/ubuntu/binance-quant/ops/dispatch-runtime.sh | redact - else - echo "dispatch-runtime.sh not found" - fi - - section "dispatch env keys" - if [ -f /home/ubuntu/.config/binance-platform/dispatch.env ]; then - sed -E 's/(^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*=).*/\1[REDACTED]/' \ - /home/ubuntu/.config/binance-platform/dispatch.env | redact - else - echo "dispatch.env not found" - fi + - name: Observe scheduler state + run: python3 -m scripts.runtime_scheduler_probe diff --git a/scripts/runtime_scheduler_probe.py b/scripts/runtime_scheduler_probe.py new file mode 100644 index 00000000..a4fdc00e --- /dev/null +++ b/scripts/runtime_scheduler_probe.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Emit a bounded read-only observation of the reviewed runtime scheduler.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import os + +from runtime_scheduler_observation import observe_runtime_scheduler_state + + +_SCHEDULER_STATES = frozenset({"enabled", "disabled", "unknown"}) + + +def observe() -> dict[str, str]: + try: + state = observe_runtime_scheduler_state() + except Exception: # noqa: BLE001 - probe failures must not expose host details + state = "unknown" + if not isinstance(state, str) or state not in _SCHEDULER_STATES: + state = "unknown" + return { + "scheduler_state": state, + "observed_at": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), + } + + +def _write_github_output(path: str, result: dict[str, str]) -> bool: + try: + with open(path, "a", encoding="utf-8") as output: + output.write( + f"scheduler_state={result['scheduler_state']}\n" + f"observed_at={result['observed_at']}\n" + ) + except OSError: + return False + return True + + +def main() -> int: + result = observe() + output_path = os.environ.get("GITHUB_OUTPUT") + if output_path and not _write_github_output(output_path, result): + result["scheduler_state"] = "unknown" + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_runtime_scheduler_observation.py b/tests/test_runtime_scheduler_observation.py index f876174e..073917b5 100644 --- a/tests/test_runtime_scheduler_observation.py +++ b/tests/test_runtime_scheduler_observation.py @@ -134,6 +134,22 @@ def test_contradictory_daemon_exit_and_state_are_unknown(self): ) self.assertEqual(state, "unknown") + def test_unavailable_daemon_probe_is_unknown(self): + exact = "0 * * * * /home/ubuntu/binance-quant/ops/dispatch-runtime.sh\n" + run = self.command_runner( + crontab=(0, exact, ""), + daemon=subprocess.TimeoutExpired(["systemctl", "is-active", "cron"], 2), + ) + with ( + patch("runtime_scheduler_observation.pwd.getpwuid", return_value=SimpleNamespace(pw_name="ubuntu")), + patch("runtime_scheduler_observation.os.stat", return_value=SimpleNamespace(st_mode=0o100755)), + patch("runtime_scheduler_observation.os.access", return_value=True), + ): + self.assertEqual( + observe_runtime_scheduler_state(environ=self.expected_environment, run_command=run), + "unknown", + ) + def test_unreviewed_host_context_does_not_run_commands(self): contexts = [] for field in self.expected_environment: diff --git a/tests/test_runtime_scheduler_probe.py b/tests/test_runtime_scheduler_probe.py new file mode 100644 index 00000000..0bfd6619 --- /dev/null +++ b/tests/test_runtime_scheduler_probe.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +from datetime import datetime +import io +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from scripts import runtime_scheduler_probe as probe + + +class RuntimeSchedulerProbeTests(unittest.TestCase): + def test_probe_emits_only_scheduler_state_and_time_without_runtime_report(self): + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "github-output.txt" + stdout = io.StringIO() + with ( + patch.dict(os.environ, {"GITHUB_OUTPUT": str(output)}, clear=True), + patch.object(probe, "observe_runtime_scheduler_state", return_value="enabled"), + redirect_stdout(stdout), + ): + self.assertEqual(probe.main(), 0) + + result = json.loads(stdout.getvalue()) + self.assertEqual(set(result), {"scheduler_state", "observed_at"}) + self.assertEqual(result["scheduler_state"], "enabled") + self.assertTrue(result["observed_at"].endswith("Z")) + datetime.fromisoformat(result["observed_at"].replace("Z", "+00:00")) + self.assertEqual( + output.read_text(encoding="utf-8").splitlines(), + [f"scheduler_state=enabled", f"observed_at={result['observed_at']}"], + ) + + def test_probe_suppresses_host_errors_and_invalid_states(self): + cases = ( + RuntimeError("private cron contents must not be printed"), + "unexpected-host-state", + ) + for value in cases: + with self.subTest(value=type(value).__name__), tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "github-output.txt" + stdout = io.StringIO() + context = ( + patch.object(probe, "observe_runtime_scheduler_state", side_effect=value) + if isinstance(value, BaseException) + else patch.object(probe, "observe_runtime_scheduler_state", return_value=value) + ) + with ( + patch.dict(os.environ, {"GITHUB_OUTPUT": str(output)}, clear=True), + context, + redirect_stdout(stdout), + ): + self.assertEqual(probe.main(), 0) + + result = json.loads(stdout.getvalue()) + self.assertEqual(result["scheduler_state"], "unknown") + self.assertEqual(set(result), {"scheduler_state", "observed_at"}) + self.assertNotIn("private cron contents", stdout.getvalue()) + self.assertEqual(output.read_text(encoding="utf-8").splitlines()[0], "scheduler_state=unknown") From 94f86fb06ae07ee824829ac6a6902982baa6e71f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:35:51 +0800 Subject: [PATCH 39/47] fix: observe scheduler independently in lifecycle monitoring Co-Authored-By: Codex --- .../workflows/runtime-target-lifecycle.yml | 67 +++- scripts/runtime_scheduler_artifact.py | 331 ++++++++++++++++++ scripts/runtime_scheduler_probe.py | 20 +- .../runtime_target_lifecycle_monitoring.py | 45 ++- tests/test_runtime_scheduler_artifact.py | 243 +++++++++++++ tests/test_runtime_scheduler_probe.py | 18 +- ...est_runtime_target_lifecycle_monitoring.py | 120 +++++-- 7 files changed, 797 insertions(+), 47 deletions(-) create mode 100644 scripts/runtime_scheduler_artifact.py create mode 100644 tests/test_runtime_scheduler_artifact.py diff --git a/.github/workflows/runtime-target-lifecycle.yml b/.github/workflows/runtime-target-lifecycle.yml index 317cc9c2..e52d8b53 100644 --- a/.github/workflows/runtime-target-lifecycle.yml +++ b/.github/workflows/runtime-target-lifecycle.yml @@ -5,16 +5,44 @@ on: schedule: - cron: "47 * * * *" -concurrency: - group: ${{ github.workflow }}-${{ github.ref_name }} - cancel-in-progress: false - jobs: + observe-scheduler: + name: Observe Binance scheduler + if: ${{ github.ref == 'refs/heads/main' }} + runs-on: self-hosted + timeout-minutes: 5 + permissions: + contents: read + concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }}-scheduler-observation + cancel-in-progress: false + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Observe scheduler state + env: + SCHEDULER_OBSERVATION_PATH: ${{ runner.temp }}/runtime-scheduler-observation.json + run: python -m scripts.runtime_scheduler_probe --output-path "$SCHEDULER_OBSERVATION_PATH" + + - name: Upload bounded scheduler observation + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: binance-scheduler-observation-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/runtime-scheduler-observation.json + if-no-files-found: error + retention-days: 1 + publish: name: Publish Binance monitor state (non-trading) if: ${{ github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest timeout-minutes: 15 + concurrency: + group: ${{ github.workflow }}-${{ github.ref_name }} + cancel-in-progress: false permissions: actions: read contents: read @@ -117,6 +145,34 @@ jobs: test -s "$RUNTIME_HEARTBEAT_OUTPUT_PATH" echo "path=$RUNTIME_HEARTBEAT_OUTPUT_PATH" >> "$GITHUB_OUTPUT" + - name: Select recent scheduler observation + id: scheduler-artifact + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python -m scripts.runtime_scheduler_artifact select + + - name: Download recent scheduler observation + if: ${{ steps.scheduler-artifact.outputs.scheduler_artifact_available == 'true' }} + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: ${{ steps.scheduler-artifact.outputs.scheduler_artifact_name }} + run-id: ${{ steps.scheduler-artifact.outputs.scheduler_artifact_run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} + path: ${{ runner.temp }}/scheduler-observation-artifact + + - name: Validate scheduler observation + id: scheduler-observation + env: + SCHEDULER_ARTIFACT_AVAILABLE: ${{ steps.scheduler-artifact.outputs.scheduler_artifact_available || 'false' }} + SCHEDULER_ARTIFACT_CREATED_AT: ${{ steps.scheduler-artifact.outputs.scheduler_artifact_created_at }} + SCHEDULER_ARTIFACT_SIZE: ${{ steps.scheduler-artifact.outputs.scheduler_artifact_size }} + run: >- + python -m scripts.runtime_scheduler_artifact validate + --artifact-path "${{ runner.temp }}/scheduler-observation-artifact/runtime-scheduler-observation.json" + --output-path "${{ runner.temp }}/validated-runtime-scheduler-observation.json" + - name: Publish monitor execution evidence if: ${{ steps.target.outputs.configured_state == 'enabled' && steps.execution-heartbeat.outcome == 'success' }} uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence@f2597e8bfb25881242b13145cb5f42d06ba3476c @@ -135,6 +191,9 @@ jobs: CONFIGURATION_GUARD: ${{ steps.target.outputs.runtime_guard }} WORKFLOW_HEARTBEAT_PATH: ${{ steps.workflow-heartbeat.outputs.path }} EXECUTION_HEARTBEAT_PATH: ${{ steps.execution-heartbeat.outputs.path }} + SCHEDULER_OBSERVATION_PATH: ${{ runner.temp }}/validated-runtime-scheduler-observation.json + TARGET_ID: ${{ steps.target.outputs.target_id }} + EXECUTION_MODE: ${{ steps.target.outputs.execution_mode }} run: python scripts/runtime_target_lifecycle_monitoring.py - name: Publish target lifecycle snapshot diff --git a/scripts/runtime_scheduler_artifact.py b/scripts/runtime_scheduler_artifact.py new file mode 100644 index 00000000..8550e780 --- /dev/null +++ b/scripts/runtime_scheduler_artifact.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Select and validate the latest bounded scheduler observation artifact.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timedelta, timezone +import json +import os +from pathlib import Path +import re +from typing import Any, Callable +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +_REPOSITORY = "QuantStrategyLab/BinancePlatform" +_WORKFLOW_PATH = ".github/workflows/runtime-target-lifecycle.yml" +_JOB_NAME = "Observe Binance scheduler" +_ARTIFACT_PREFIX = "binance-scheduler-observation-" +_MAX_AGE = timedelta(minutes=75) +_MAX_API_BODY = 256 * 1024 +_MAX_ARTIFACT_SIZE = 1024 +_MAX_RUNS = 10 +_MAX_JOBS = 10 +_MAX_ARTIFACTS = 10 +_VALID_STATES = frozenset({"enabled", "disabled", "unknown"}) +_SHA256 = re.compile(r"^[0-9a-f]{40}$") + + +class _NoRedirectHandler(HTTPRedirectHandler): + def redirect_request(self, request, file_pointer, code, message, headers, new_url): + return None + + +_OPENER = build_opener(_NoRedirectHandler()) + + +def _timestamp(value: object) -> datetime | None: + if not isinstance(value, str): + return None + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return parsed.astimezone(timezone.utc) + + +def _pairs_without_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON key") + result[key] = value + return result + + +def _api_base(value: str | None) -> str | None: + raw = (value or "").rstrip("/") + parsed = urlsplit(raw) + if parsed.scheme != "https" or parsed.netloc != "api.github.com" or parsed.path: + return None + return raw + + +def _api_json(url: str, token: str) -> dict[str, Any] | None: + request = Request( + url, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "Binance-Runtime-Scheduler-Observer", + "Cache-Control": "no-cache", + }, + ) + try: + with _OPENER.open(request, timeout=10) as response: + if response.status != 200: + return None + body = response.read(_MAX_API_BODY + 1) + except (HTTPError, URLError, OSError, TimeoutError): + return None + if len(body) > _MAX_API_BODY: + return None + try: + payload = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + return payload if isinstance(payload, dict) else None + + +def _is_repository_run(run: object, now: datetime) -> bool: + if not isinstance(run, dict): + return False + repository = run.get("repository") + created_at = _timestamp(run.get("created_at")) + run_id = run.get("id") + attempt = run.get("run_attempt") + return bool( + type(run_id) is int + and run_id > 0 + and type(attempt) is int + and attempt > 0 + and isinstance(repository, dict) + and repository.get("full_name") == _REPOSITORY + and str(run.get("path") or "").split("@", 1)[0] == _WORKFLOW_PATH + and run.get("head_branch") == "main" + and run.get("event") in {"schedule", "workflow_dispatch"} + and isinstance(run.get("head_sha"), str) + and _SHA256.fullmatch(run["head_sha"]) is not None + and created_at is not None + and created_at <= now + and now - created_at <= _MAX_AGE + ) + + +def select_latest_observation( + *, + token: str, + api_base: str, + now: datetime | None = None, + get_json: Callable[[str, str], dict[str, Any] | None] = _api_json, +) -> dict[str, str] | None: + """Return a bounded locator for the latest successful main observer job.""" + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + if not token or _api_base(api_base) is None: + return None + base = api_base.rstrip("/") + runs_url = ( + f"{base}/repos/{_REPOSITORY}/actions/workflows/runtime-target-lifecycle.yml" + "/runs?branch=main&per_page=10" + ) + runs_payload = get_json(runs_url, token) + rows = runs_payload.get("workflow_runs") if isinstance(runs_payload, dict) else None + if not isinstance(rows, list) or len(rows) > _MAX_RUNS: + return None + runs = [run for run in rows if _is_repository_run(run, current)] + runs.sort(key=lambda run: _timestamp(run["created_at"]) or current, reverse=True) + + inspected = 0 + for run in runs: + if inspected >= 5: + break + inspected += 1 + run_id = run["id"] + attempt = run["run_attempt"] + jobs_url = f"{base}/repos/{_REPOSITORY}/actions/runs/{run_id}/jobs?per_page=10" + jobs_payload = get_json(jobs_url, token) + jobs = jobs_payload.get("jobs") if isinstance(jobs_payload, dict) else None + if not isinstance(jobs, list) or len(jobs) > _MAX_JOBS: + continue + matching_jobs = [job for job in jobs if isinstance(job, dict) and job.get("name") == _JOB_NAME] + if len(matching_jobs) != 1: + continue + job = matching_jobs[0] + if job.get("status") != "completed" or job.get("conclusion") != "success": + continue + + artifacts_url = f"{base}/repos/{_REPOSITORY}/actions/runs/{run_id}/artifacts?per_page=10" + artifacts_payload = get_json(artifacts_url, token) + artifacts = artifacts_payload.get("artifacts") if isinstance(artifacts_payload, dict) else None + if not isinstance(artifacts, list) or len(artifacts) > _MAX_ARTIFACTS: + continue + expected_name = f"{_ARTIFACT_PREFIX}{run_id}-{attempt}" + matches = [artifact for artifact in artifacts if isinstance(artifact, dict) and artifact.get("name") == expected_name] + if len(matches) != 1: + continue + artifact = matches[0] + created_at = _timestamp(artifact.get("created_at")) + expires_at = _timestamp(artifact.get("expires_at")) + size = artifact.get("size_in_bytes") + if ( + artifact.get("expired") is not False + or type(size) is not int + or size <= 0 + or size > _MAX_ARTIFACT_SIZE + or created_at is None + or created_at > current + or current - created_at > _MAX_AGE + or expires_at is None + or expires_at <= current + ): + continue + return { + "run_id": str(run_id), + "artifact_name": expected_name, + "artifact_created_at": created_at.isoformat().replace("+00:00", "Z"), + "artifact_size": str(size), + } + return None + + +def validate_observation( + *, + artifact_path: str, + artifact_created_at: str, + artifact_size: str, + available: str, + now: datetime | None = None, +) -> dict[str, str]: + current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc) + fallback = { + "scheduler_state": "unknown", + "observed_at": "", + } + try: + metadata_time = _timestamp(artifact_created_at) + metadata_size = int(artifact_size) + path = Path(artifact_path) + if ( + available != "true" + or metadata_time is None + or metadata_time > current + or current - metadata_time > _MAX_AGE + or metadata_size <= 0 + or metadata_size > _MAX_ARTIFACT_SIZE + or path.is_symlink() + or not path.is_file() + or path.stat().st_size <= 0 + or path.stat().st_size > _MAX_ARTIFACT_SIZE + ): + return fallback + body = path.read_bytes() + if len(body) > _MAX_ARTIFACT_SIZE: + return fallback + payload = json.loads(body, object_pairs_hook=_pairs_without_duplicates) + if not isinstance(payload, dict) or set(payload) != {"scheduler_state", "observed_at"}: + return fallback + state = payload.get("scheduler_state") + observed_at_raw = payload.get("observed_at") + observed_at = _timestamp(observed_at_raw) + if ( + not isinstance(state, str) + or state not in _VALID_STATES + or not isinstance(observed_at_raw, str) + or not observed_at_raw.endswith("Z") + or observed_at is None + or observed_at > current + or observed_at > metadata_time + or current - observed_at > _MAX_AGE + ): + return fallback + return {"scheduler_state": state, "observed_at": observed_at.isoformat(timespec="seconds").replace("+00:00", "Z")} + except (OSError, ValueError, UnicodeDecodeError, json.JSONDecodeError): + return fallback + + +def _write_outputs(values: dict[str, str]) -> None: + path = os.environ.get("GITHUB_OUTPUT") + if path: + with open(path, "a", encoding="utf-8") as output: + for key, value in values.items(): + output.write(f"{key}={value}\n") + + +def _select_main() -> int: + try: + api_base = _api_base(os.environ.get("GITHUB_API_URL")) + repository = os.environ.get("GITHUB_REPOSITORY") + candidate = None + if repository == _REPOSITORY and api_base is not None: + candidate = select_latest_observation( + token=os.environ.get("GITHUB_TOKEN", ""), + api_base=api_base, + ) + except Exception: # noqa: BLE001 - metadata failures are a safe unknown + candidate = None + values = { + "scheduler_artifact_available": "true" if candidate is not None else "false", + "scheduler_artifact_run_id": candidate["run_id"] if candidate else "", + "scheduler_artifact_name": candidate["artifact_name"] if candidate else "", + "scheduler_artifact_created_at": candidate["artifact_created_at"] if candidate else "", + "scheduler_artifact_size": candidate["artifact_size"] if candidate else "", + } + try: + _write_outputs(values) + except OSError: + values["scheduler_artifact_available"] = "false" + print(json.dumps({"scheduler_artifact_available": values["scheduler_artifact_available"]}, sort_keys=True)) + return 0 + + +def _validate_main(artifact_path: str, output_path: str) -> int: + result = validate_observation( + artifact_path=artifact_path, + artifact_created_at=os.environ.get("SCHEDULER_ARTIFACT_CREATED_AT", ""), + artifact_size=os.environ.get("SCHEDULER_ARTIFACT_SIZE", ""), + available=os.environ.get("SCHEDULER_ARTIFACT_AVAILABLE", "false"), + ) + try: + Path(output_path).write_text(json.dumps(result, sort_keys=True) + "\n", encoding="utf-8") + except OSError: + result = { + "scheduler_state": "unknown", + "observed_at": "", + } + print(json.dumps(result, sort_keys=True)) + return 1 + try: + _write_outputs(result) + except OSError: + result = { + "scheduler_state": "unknown", + "observed_at": "", + } + try: + Path(output_path).write_text(json.dumps(result, sort_keys=True) + "\n", encoding="utf-8") + except OSError: + print(json.dumps(result, sort_keys=True)) + return 1 + print(json.dumps(result, sort_keys=True)) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser("select") + validate = subparsers.add_parser("validate") + validate.add_argument("--artifact-path", required=True) + validate.add_argument("--output-path", required=True) + args = parser.parse_args(argv) + return _select_main() if args.command == "select" else _validate_main(args.artifact_path, args.output_path) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/runtime_scheduler_probe.py b/scripts/runtime_scheduler_probe.py index a4fdc00e..5b9609bb 100644 --- a/scripts/runtime_scheduler_probe.py +++ b/scripts/runtime_scheduler_probe.py @@ -3,9 +3,11 @@ from __future__ import annotations +import argparse from datetime import datetime, timezone import json import os +from pathlib import Path from runtime_scheduler_observation import observe_runtime_scheduler_state @@ -38,11 +40,27 @@ def _write_github_output(path: str, result: dict[str, str]) -> bool: return True -def main() -> int: +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output-path") + args = parser.parse_args(argv) result = observe() + if args.output_path: + try: + Path(args.output_path).write_text(json.dumps(result, sort_keys=True) + "\n", encoding="utf-8") + except OSError: + result["scheduler_state"] = "unknown" + print(json.dumps(result, sort_keys=True)) + return 1 output_path = os.environ.get("GITHUB_OUTPUT") if output_path and not _write_github_output(output_path, result): result["scheduler_state"] = "unknown" + if args.output_path: + try: + Path(args.output_path).write_text(json.dumps(result, sort_keys=True) + "\n", encoding="utf-8") + except OSError: + print(json.dumps(result, sort_keys=True)) + return 1 print(json.dumps(result, sort_keys=True)) return 0 diff --git a/scripts/runtime_target_lifecycle_monitoring.py b/scripts/runtime_target_lifecycle_monitoring.py index 62e623c2..9cdcfd7e 100644 --- a/scripts/runtime_target_lifecycle_monitoring.py +++ b/scripts/runtime_target_lifecycle_monitoring.py @@ -6,11 +6,12 @@ import json import os import re -from datetime import datetime +from datetime import datetime, timezone _CHECKS = frozenset({"pass", "attention", "not_due", "not_applicable", "unavailable"}) _DEPLOYMENT_FIELDS = ("runtime_enabled", "scheduler_state", "strategy_profile", "execution_mode", "observed_at") +_SCHEDULER_STATES = {"enabled": "enabled", "disabled": "paused", "unknown": "unknown"} def _read_assessment(path: str | None) -> dict: @@ -45,6 +46,7 @@ def _execution_check(status: str) -> str: def _deployment_json(value: object) -> str | None: + """Validate a deployment observation carried by a qualified execution report.""" if not isinstance(value, dict) or not all(field in value for field in _DEPLOYMENT_FIELDS): return None runtime_enabled = value.get("runtime_enabled") @@ -71,6 +73,37 @@ def _deployment_json(value: object) -> str | None: return json.dumps({key: value[key] for key in _DEPLOYMENT_FIELDS}, sort_keys=True) +def _scheduler_deployment_json(value: object, *, target_id: str, execution_mode: str) -> str | None: + """Build a scheduler-only snapshot without claiming Runtime enablement.""" + if not isinstance(target_id, str) or not re.fullmatch(r"binance\.[A-Za-z0-9][A-Za-z0-9._=-]{0,127}", target_id): + return None + if execution_mode not in {"live", "paper", "dry_run"}: + return None + if not isinstance(value, dict) or set(value) != {"scheduler_state", "observed_at"}: + return None + raw_state = value.get("scheduler_state") + raw_observed = value.get("observed_at") + if not isinstance(raw_state, str) or raw_state not in _SCHEDULER_STATES or not isinstance(raw_observed, str): + return None + if not raw_observed.endswith("Z"): + return None + try: + parsed = datetime.fromisoformat(raw_observed.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + return None + profile = target_id.removeprefix("binance.") + deployment = { + "runtime_enabled": None, + "scheduler_state": _SCHEDULER_STATES[raw_state], + "strategy_profile": profile, + "execution_mode": execution_mode, + "observed_at": parsed.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"), + } + return json.dumps({key: deployment[key] for key in _DEPLOYMENT_FIELDS}, sort_keys=True) + + def resolve_monitoring( *, configured_state: str, @@ -101,14 +134,20 @@ def main() -> int: configuration_guard = os.environ.get("CONFIGURATION_GUARD") or "attention" workflow = _read_assessment(os.environ.get("WORKFLOW_HEARTBEAT_PATH")) execution = _read_assessment(os.environ.get("EXECUTION_HEARTBEAT_PATH")) + scheduler = _read_assessment(os.environ.get("SCHEDULER_OBSERVATION_PATH")) values = resolve_monitoring( configured_state=configured_state, configuration_guard=configuration_guard, workflow_status=str(workflow.get("status") or "unavailable"), execution_status=str(execution.get("status") or "unavailable"), ) - deployment = execution.get("deployment") - deployment_json = _deployment_json(deployment) + deployment_json = _deployment_json(execution.get("deployment")) + if deployment_json is None: + deployment_json = _scheduler_deployment_json( + scheduler, + target_id=os.environ.get("TARGET_ID", ""), + execution_mode=os.environ.get("EXECUTION_MODE", ""), + ) if deployment_json is not None: values["deployment_json"] = deployment_json output = os.environ.get("GITHUB_OUTPUT") diff --git a/tests/test_runtime_scheduler_artifact.py b/tests/test_runtime_scheduler_artifact.py new file mode 100644 index 00000000..63aecbdc --- /dev/null +++ b/tests/test_runtime_scheduler_artifact.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +import json +import os +from pathlib import Path +import tempfile +import unittest +from urllib.error import HTTPError +from unittest.mock import patch + +from scripts import runtime_scheduler_artifact as artifact + + +NOW = datetime(2026, 10, 8, 12, 0, tzinfo=timezone.utc) + + +def _iso(value: datetime) -> str: + return value.isoformat(timespec="seconds").replace("+00:00", "Z") + + +def _api_fixtures(*, run_overrides=None, job_overrides=None, artifact_overrides=None): + run = { + "id": 123456, + "run_attempt": 1, + "repository": {"full_name": artifact._REPOSITORY}, + "path": artifact._WORKFLOW_PATH, + "head_branch": "main", + "head_sha": "a" * 40, + "event": "schedule", + "created_at": _iso(NOW - timedelta(minutes=3)), + } + run.update(run_overrides or {}) + job = {"name": artifact._JOB_NAME, "status": "completed", "conclusion": "success"} + job.update(job_overrides or {}) + artifact_row = { + "name": "binance-scheduler-observation-123456-1", + "expired": False, + "size_in_bytes": 125, + "created_at": _iso(NOW - timedelta(minutes=2)), + "expires_at": _iso(NOW + timedelta(days=1)), + } + artifact_row.update(artifact_overrides or {}) + + def get_json(url, token): + if "/actions/workflows/" in url: + return {"workflow_runs": [run]} + if "/jobs?" in url: + return {"jobs": [job]} + if "/artifacts?" in url: + return {"artifacts": [artifact_row]} + raise AssertionError("unexpected endpoint") + + return get_json + + +class RuntimeSchedulerArtifactTests(unittest.TestCase): + def test_selects_only_recent_successful_main_schedule_and_exact_attempt_artifact(self): + selected = artifact.select_latest_observation( + token="synthetic-token", + api_base="https://api.github.com", + now=NOW, + get_json=_api_fixtures(), + ) + self.assertEqual(selected, { + "run_id": "123456", + "artifact_name": "binance-scheduler-observation-123456-1", + "artifact_created_at": _iso(NOW - timedelta(minutes=2)), + "artifact_size": "125", + }) + + def test_rejects_wrong_run_context_or_unsuccessful_observer(self): + cases = ( + ({"repository": {"full_name": "someone/else"}}, {}, {}), + ({"path": ".github/workflows/other.yml"}, {}, {}), + ({"head_branch": "runtime-production"}, {}, {}), + ({"event": "push"}, {}, {}), + ({}, {"conclusion": "failure"}, {}), + ({}, {"name": "Other job"}, {}), + ) + for run, job, row in cases: + with self.subTest(run=run, job=job): + selected = artifact.select_latest_observation( + token="synthetic-token", + api_base="https://api.github.com", + now=NOW, + get_json=_api_fixtures(run_overrides=run, job_overrides=job, artifact_overrides=row), + ) + self.assertIsNone(selected) + + def test_selects_manual_observation_only_from_main(self): + selected = artifact.select_latest_observation( + token="synthetic-token", + api_base="https://api.github.com", + now=NOW, + get_json=_api_fixtures(run_overrides={"event": "workflow_dispatch"}), + ) + self.assertIsNotNone(selected) + + def test_rejects_expired_old_future_and_oversized_artifacts(self): + cases = ( + {"expired": True}, + {"size_in_bytes": 1025}, + {"created_at": _iso(NOW - timedelta(minutes=76))}, + {"created_at": _iso(NOW + timedelta(seconds=1))}, + {"expires_at": _iso(NOW)}, + {"name": "binance-scheduler-observation-123456-2"}, + ) + for row in cases: + with self.subTest(row=row): + selected = artifact.select_latest_observation( + token="synthetic-token", + api_base="https://api.github.com", + now=NOW, + get_json=_api_fixtures(artifact_overrides=row), + ) + self.assertIsNone(selected) + + def test_rejects_untrusted_api_base_and_bounded_api_payloads(self): + self.assertIsNone(artifact.select_latest_observation( + token="synthetic-token", api_base="https://attacker.invalid", now=NOW, get_json=_api_fixtures(), + )) + self.assertIsNone(artifact._api_base("https://api.github.com/other")) + + def test_api_does_not_follow_redirects_or_accept_oversized_metadata(self): + redirect = HTTPError("https://api.github.com/safe", 302, "redirect", {}, None) + with patch.object(artifact._OPENER, "open", side_effect=redirect) as open_request: + self.assertIsNone(artifact._api_json("https://api.github.com/safe", "synthetic-token")) + open_request.assert_called_once() + + class OversizedResponse: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self, size): + self.requested_size = size + return b"x" * size + + response = OversizedResponse() + with patch.object(artifact._OPENER, "open", return_value=response): + self.assertIsNone(artifact._api_json("https://api.github.com/safe", "synthetic-token")) + self.assertEqual(response.requested_size, artifact._MAX_API_BODY + 1) + + def test_validates_only_fresh_two_field_observation(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "runtime-scheduler-observation.json" + path.write_text(json.dumps({ + "scheduler_state": "disabled", + "observed_at": _iso(NOW - timedelta(minutes=3)), + }), encoding="utf-8") + result = artifact.validate_observation( + artifact_path=str(path), + artifact_created_at=_iso(NOW - timedelta(minutes=2)), + artifact_size=str(path.stat().st_size), + available="true", + now=NOW, + ) + self.assertEqual(result, { + "scheduler_state": "disabled", + "observed_at": _iso(NOW - timedelta(minutes=3)), + }) + + def test_invalid_body_metadata_staleness_future_and_symlink_become_unknown(self): + payloads = ( + b'{"scheduler_state":"enabled","observed_at":"2026-10-08T11:58:00Z","scheduler_state":"disabled"}', + b'{"scheduler_state":"enabled","observed_at":"2026-10-08T11:58:00Z","extra":true}', + b'{"scheduler_state":"active","observed_at":"2026-10-08T11:58:00Z"}', + b'{"scheduler_state":"enabled","observed_at":"2026-10-08T13:00:00Z"}', + b'{"scheduler_state":"enabled","observed_at":"2026-10-08T10:44:00Z"}', + b"x" * 1025, + ) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "observation.json" + for body in payloads: + with self.subTest(body=body[:80]): + path.write_bytes(body) + result = artifact.validate_observation( + artifact_path=str(path), + artifact_created_at=_iso(NOW - timedelta(minutes=2)), + artifact_size="125", + available="true", + now=NOW, + ) + self.assertEqual(result["scheduler_state"], "unknown") + self.assertEqual(artifact.validate_observation( + artifact_path=str(path), + artifact_created_at=_iso(NOW + timedelta(seconds=1)), + artifact_size="125", + available="true", + now=NOW, + )["scheduler_state"], "unknown") + path.unlink() + target = Path(directory) / "target.json" + target.write_text('{"scheduler_state":"enabled","observed_at":"2026-10-08T11:58:00Z"}', encoding="utf-8") + path.symlink_to(target) + self.assertEqual(artifact.validate_observation( + artifact_path=str(path), + artifact_created_at=_iso(NOW - timedelta(minutes=2)), + artifact_size="125", + available="true", + now=NOW, + )["scheduler_state"], "unknown") + + def test_cli_selector_fails_closed_when_repository_or_api_unavailable(self): + output = tempfile.NamedTemporaryFile(delete=False) + output.close() + try: + with patch.dict(os.environ, { + "GITHUB_OUTPUT": output.name, + "GITHUB_REPOSITORY": "someone/else", + "GITHUB_API_URL": "https://api.github.com", + "GITHUB_TOKEN": "synthetic-token", + }, clear=True): + self.assertEqual(artifact.main(["select"]), 0) + self.assertIn("scheduler_artifact_available=false", Path(output.name).read_text(encoding="utf-8")) + finally: + Path(output.name).unlink(missing_ok=True) + + def test_workflow_observer_is_independent_and_has_separate_concurrency(self): + workflow = Path(__file__).resolve().parents[1] / ".github/workflows/runtime-target-lifecycle.yml" + text = workflow.read_text(encoding="utf-8") + observer = text.split(" observe-scheduler:", 1)[1].split(" publish:", 1)[0] + publish = text.split(" publish:", 1)[1] + self.assertNotIn("needs:", observer) + self.assertIn("if: ${{ github.ref == 'refs/heads/main' }}", observer) + self.assertIn("contents: read", observer) + self.assertNotIn("actions: write", observer) + self.assertIn("retention-days: 1", observer) + self.assertIn("group: ${{ github.workflow }}-${{ github.ref_name }}-scheduler-observation", observer) + self.assertIn("group: ${{ github.workflow }}-${{ github.ref_name }}", publish) + self.assertNotIn("\nconcurrency:\n", text) + self.assertIn("actions: read", publish) + self.assertIn("actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", observer) + self.assertIn("actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", publish) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime_scheduler_probe.py b/tests/test_runtime_scheduler_probe.py index 0bfd6619..1399d4b8 100644 --- a/tests/test_runtime_scheduler_probe.py +++ b/tests/test_runtime_scheduler_probe.py @@ -17,19 +17,21 @@ class RuntimeSchedulerProbeTests(unittest.TestCase): def test_probe_emits_only_scheduler_state_and_time_without_runtime_report(self): with tempfile.TemporaryDirectory() as directory: output = Path(directory) / "github-output.txt" + artifact = Path(directory) / "runtime-scheduler-observation.json" stdout = io.StringIO() with ( patch.dict(os.environ, {"GITHUB_OUTPUT": str(output)}, clear=True), patch.object(probe, "observe_runtime_scheduler_state", return_value="enabled"), redirect_stdout(stdout), ): - self.assertEqual(probe.main(), 0) + self.assertEqual(probe.main(["--output-path", str(artifact)]), 0) result = json.loads(stdout.getvalue()) self.assertEqual(set(result), {"scheduler_state", "observed_at"}) self.assertEqual(result["scheduler_state"], "enabled") self.assertTrue(result["observed_at"].endswith("Z")) datetime.fromisoformat(result["observed_at"].replace("Z", "+00:00")) + self.assertEqual(json.loads(artifact.read_text(encoding="utf-8")), result) self.assertEqual( output.read_text(encoding="utf-8").splitlines(), [f"scheduler_state=enabled", f"observed_at={result['observed_at']}"], @@ -54,10 +56,22 @@ def test_probe_suppresses_host_errors_and_invalid_states(self): context, redirect_stdout(stdout), ): - self.assertEqual(probe.main(), 0) + self.assertEqual(probe.main([]), 0) result = json.loads(stdout.getvalue()) self.assertEqual(result["scheduler_state"], "unknown") self.assertEqual(set(result), {"scheduler_state", "observed_at"}) self.assertNotIn("private cron contents", stdout.getvalue()) self.assertEqual(output.read_text(encoding="utf-8").splitlines()[0], "scheduler_state=unknown") + + def test_failed_github_output_cannot_leave_an_enabled_artifact(self): + with tempfile.TemporaryDirectory() as directory: + artifact = Path(directory) / "runtime-scheduler-observation.json" + missing_parent_output = Path(directory) / "missing" / "github-output.txt" + with ( + patch.dict(os.environ, {"GITHUB_OUTPUT": str(missing_parent_output)}, clear=True), + patch.object(probe, "observe_runtime_scheduler_state", return_value="enabled"), + redirect_stdout(io.StringIO()), + ): + self.assertEqual(probe.main(["--output-path", str(artifact)]), 0) + self.assertEqual(json.loads(artifact.read_text(encoding="utf-8"))["scheduler_state"], "unknown") diff --git a/tests/test_runtime_target_lifecycle_monitoring.py b/tests/test_runtime_target_lifecycle_monitoring.py index add6ab44..55d42c56 100644 --- a/tests/test_runtime_target_lifecycle_monitoring.py +++ b/tests/test_runtime_target_lifecycle_monitoring.py @@ -10,72 +10,114 @@ class RuntimeTargetLifecycleMonitoringTests(unittest.TestCase): - def test_report_observation_is_forwarded_without_changing_its_time(self): - deployment = { - "runtime_enabled": None, - "scheduler_state": "unknown", - "strategy_profile": "crypto_live_pool_rotation", - "execution_mode": "dry_run", - "observed_at": "2026-08-30T02:00:00Z", - } + def test_scheduler_only_snapshot_does_not_claim_runtime_enablement(self): with tempfile.TemporaryDirectory() as directory: assessment = Path(directory) / "assessment.json" + scheduler = Path(directory) / "scheduler.json" output = Path(directory) / "output.txt" - assessment.write_text(json.dumps({"status": "healthy", "deployment": deployment}), encoding="utf-8") + observed_at = "2026-08-30T02:00:00Z" + assessment.write_text(json.dumps({"status": "healthy", "deployment": {"runtime_enabled": True}}), encoding="utf-8") + scheduler.write_text(json.dumps({"scheduler_state": "enabled", "observed_at": observed_at}), encoding="utf-8") with patch.dict(os.environ, { "CONFIGURED_STATE": "enabled", "CONFIGURATION_GUARD": "pass", "EXECUTION_HEARTBEAT_PATH": str(assessment), + "SCHEDULER_OBSERVATION_PATH": str(scheduler), + "TARGET_ID": "binance.crypto_live_pool_rotation", + "EXECUTION_MODE": "dry_run", "GITHUB_OUTPUT": str(output), }, clear=True): self.assertEqual(monitoring.main(), 0) values = dict(line.split("=", 1) for line in output.read_text().splitlines()) - self.assertEqual(json.loads(values["deployment_json"]), deployment) + deployment = json.loads(values["deployment_json"]) + self.assertEqual(deployment, { + "runtime_enabled": None, + "scheduler_state": "enabled", + "strategy_profile": "crypto_live_pool_rotation", + "execution_mode": "dry_run", + "observed_at": observed_at, + }) - def test_alert_does_not_hide_valid_report_deployment(self): - deployment = { + def test_qualified_execution_report_deployment_keeps_priority_and_original_time(self): + report_deployment = { "runtime_enabled": True, "scheduler_state": "enabled", "strategy_profile": "crypto_live_pool_rotation", - "execution_mode": "dry_run", - "observed_at": "2026-08-30T02:00:00Z", + "execution_mode": "paper", + "observed_at": "2026-08-30T01:55:00Z", } with tempfile.TemporaryDirectory() as directory: assessment = Path(directory) / "assessment.json" + scheduler = Path(directory) / "scheduler.json" + output = Path(directory) / "output.txt" + assessment.write_text(json.dumps({"status": "healthy", "deployment": report_deployment}), encoding="utf-8") + scheduler.write_text(json.dumps({"scheduler_state": "disabled", "observed_at": "2026-08-30T02:00:00Z"}), encoding="utf-8") + with patch.dict(os.environ, { + "CONFIGURED_STATE": "enabled", + "CONFIGURATION_GUARD": "pass", + "EXECUTION_HEARTBEAT_PATH": str(assessment), + "SCHEDULER_OBSERVATION_PATH": str(scheduler), + "TARGET_ID": "binance.crypto_live_pool_rotation", + "EXECUTION_MODE": "dry_run", + "GITHUB_OUTPUT": str(output), + }, clear=True): + self.assertEqual(monitoring.main(), 0) + values = dict(line.split("=", 1) for line in output.read_text().splitlines()) + self.assertEqual(json.loads(values["deployment_json"]), report_deployment) + + def test_alert_does_not_hide_scheduler_snapshot(self): + with tempfile.TemporaryDirectory() as directory: + assessment = Path(directory) / "assessment.json" + scheduler = Path(directory) / "scheduler.json" output = Path(directory) / "output.txt" - assessment.write_text(json.dumps({"status": "alert", "deployment": deployment}), encoding="utf-8") + assessment.write_text(json.dumps({"status": "alert", "deployment": {"runtime_enabled": True}}), encoding="utf-8") + scheduler.write_text(json.dumps({"scheduler_state": "disabled", "observed_at": "2026-08-30T02:00:00Z"}), encoding="utf-8") with patch.dict(os.environ, { "CONFIGURED_STATE": "enabled", "CONFIGURATION_GUARD": "pass", "EXECUTION_HEARTBEAT_PATH": str(assessment), + "SCHEDULER_OBSERVATION_PATH": str(scheduler), + "TARGET_ID": "binance.crypto_live_pool_rotation", + "EXECUTION_MODE": "dry_run", "GITHUB_OUTPUT": str(output), }, clear=True): self.assertEqual(monitoring.main(), 0) values = dict(line.split("=", 1) for line in output.read_text().splitlines()) - self.assertEqual(json.loads(values["deployment_json"]), deployment) + self.assertEqual(json.loads(values["deployment_json"])["scheduler_state"], "paused") self.assertEqual(values["execution_heartbeat"], "attention") - def test_malformed_deployment_is_not_forwarded(self): - invalid_deployments = ( - {"runtime_enabled": "true"}, - {"runtime_enabled": False, "scheduler_state": "enabled", "strategy_profile": "x", "execution_mode": "dry_run", "observed_at": "2026-08-30T02:00:00Z"}, - {"runtime_enabled": None, "scheduler_state": [], "strategy_profile": "x", "execution_mode": "dry_run", "observed_at": "2026-08-30T02:00:00Z"}, - {"runtime_enabled": None, "scheduler_state": "unknown", "strategy_profile": "x", "execution_mode": "unknown", "observed_at": "2026-08-30T02:00:00Z"}, - {"runtime_enabled": None, "scheduler_state": "unknown", "strategy_profile": "x", "execution_mode": "dry_run", "observed_at": "2026-08-30T02:00:00"}, + def test_missing_or_invalid_scheduler_observation_does_not_forge_a_fresh_time(self): + self.assertIsNone(monitoring._scheduler_deployment_json( + {"scheduler_state": "unknown", "observed_at": ""}, + target_id="binance.profile", + execution_mode="paper", + )) + self.assertIsNone(monitoring._scheduler_deployment_json( + None, target_id="binance.profile", execution_mode="paper", + )) + + def test_scheduler_unknown_with_real_observation_time_is_forwarded(self): + result = monitoring._scheduler_deployment_json( + {"scheduler_state": "unknown", "observed_at": "2026-08-30T02:00:00Z"}, + target_id="binance.profile", + execution_mode="paper", ) - for deployment in invalid_deployments: - with self.subTest(deployment=deployment), tempfile.TemporaryDirectory() as directory: - assessment = Path(directory) / "assessment.json" - output = Path(directory) / "output.txt" - assessment.write_text(json.dumps({"status": "alert", "deployment": deployment}), encoding="utf-8") - with patch.dict(os.environ, { - "CONFIGURED_STATE": "enabled", - "CONFIGURATION_GUARD": "pass", - "EXECUTION_HEARTBEAT_PATH": str(assessment), - "GITHUB_OUTPUT": str(output), - }, clear=True): - self.assertEqual(monitoring.main(), 0) - self.assertNotIn("deployment_json", output.read_text()) + self.assertEqual(json.loads(result), { + "runtime_enabled": None, + "scheduler_state": "unknown", + "strategy_profile": "profile", + "execution_mode": "paper", + "observed_at": "2026-08-30T02:00:00Z", + }) + + def test_invalid_target_or_execution_mode_is_not_forwarded(self): + observation = {"scheduler_state": "enabled", "observed_at": "2026-08-30T02:00:00Z"} + self.assertIsNone(monitoring._scheduler_deployment_json( + observation, target_id="binance.unresolved/secret", execution_mode="paper", + )) + self.assertIsNone(monitoring._scheduler_deployment_json( + observation, target_id="binance.profile", execution_mode="unknown", + )) def test_missing_report_does_not_synthesize_environment_observation(self): with tempfile.TemporaryDirectory() as directory: @@ -85,14 +127,18 @@ def test_missing_report_does_not_synthesize_environment_observation(self): "CONFIGURATION_GUARD": "pass", "RUNTIME_TARGET_ENABLED": "true", "BINANCE_DRY_RUN": "false", + "SCHEDULER_OBSERVATION_PATH": str(Path(directory) / "missing-scheduler.json"), + "TARGET_ID": "binance.profile", + "EXECUTION_MODE": "paper", "GITHUB_OUTPUT": str(output), }, clear=True): self.assertEqual(monitoring.main(), 0) self.assertNotIn("deployment_json", output.read_text()) - def test_lifecycle_passes_only_report_observation_to_shared_publisher(self): + def test_lifecycle_passes_validated_scheduler_observation_to_shared_publisher(self): workflow = Path(".github/workflows/runtime-target-lifecycle.yml").read_text(encoding="utf-8") self.assertIn("deployment-json: ${{ steps.monitoring.outputs.deployment_json }}", workflow) + self.assertIn("SCHEDULER_OBSERVATION_PATH: ${{ runner.temp }}/validated-runtime-scheduler-observation.json", workflow) self.assertNotIn("observe-gcp:", workflow) def test_enabled_target_requires_both_workflow_and_execution_evidence(self): From 9f076993cf73511c78b518cd5f7d8ab1f49a401c Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:51:45 +0800 Subject: [PATCH 40/47] fix: use available python3 on scheduler observer host (#358) Co-authored-by: Codex --- .github/workflows/runtime-target-lifecycle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/runtime-target-lifecycle.yml b/.github/workflows/runtime-target-lifecycle.yml index e52d8b53..63ba73b8 100644 --- a/.github/workflows/runtime-target-lifecycle.yml +++ b/.github/workflows/runtime-target-lifecycle.yml @@ -25,7 +25,7 @@ jobs: - name: Observe scheduler state env: SCHEDULER_OBSERVATION_PATH: ${{ runner.temp }}/runtime-scheduler-observation.json - run: python -m scripts.runtime_scheduler_probe --output-path "$SCHEDULER_OBSERVATION_PATH" + run: python3 -m scripts.runtime_scheduler_probe --output-path "$SCHEDULER_OBSERVATION_PATH" - name: Upload bounded scheduler observation uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 From 9d4c75dfcb739f56447510436b4df20d267bc7e5 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:55:47 +0800 Subject: [PATCH 41/47] refactor(b09): bind trend universe to RunContext during execute_cycle (#359) Move cycle-scoped TREND_UNIVERSE mutations onto RunContext/ContextVar so module globals are not rewritten across cycles. Keep unbound fallback for compat. Do not change STRATEGY_RUNTIME activation or submission/owner rules. --- main.py | 41 ++++++++---- run_context.py | 59 +++++++++++++++++ runtime_support.py | 1 + tests/test_run_context.py | 133 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 run_context.py create mode 100644 tests/test_run_context.py diff --git a/main.py b/main.py index a0be7c06..682d8360 100644 --- a/main.py +++ b/main.py @@ -101,6 +101,13 @@ map_strategy_decision_to_rotation_plan as map_decision_to_rotation_plan, ) from strategy_runtime import load_strategy_runtime +from run_context import ( + RunContext, + bind_run_context, + reset_run_context, + resolve_trend_universe, + set_active_trend_universe, +) from strategy_registry import DEFAULT_STRATEGY_PROFILE from trade_state_support import ( build_default_state as ts_build_default_state, @@ -452,7 +459,7 @@ def update_trend_pool_state(state, resolution): def build_default_state(): return ts_build_default_state( - trend_universe=TREND_UNIVERSE, + trend_universe=resolve_trend_universe(TREND_UNIVERSE), last_good_payload_key=TREND_POOL_LAST_GOOD_PAYLOAD_KEY, action_history_key=TREND_POOL_ACTION_HISTORY_KEY, retired_positions_key=RETIRED_TREND_POSITIONS_KEY, @@ -462,7 +469,7 @@ def build_default_state(): def normalize_trade_state(state): return ts_normalize_trade_state( state, - trend_universe=TREND_UNIVERSE, + trend_universe=resolve_trend_universe(TREND_UNIVERSE), last_good_payload_key=TREND_POOL_LAST_GOOD_PAYLOAD_KEY, action_history_key=TREND_POOL_ACTION_HISTORY_KEY, retired_positions_key=RETIRED_TREND_POSITIONS_KEY, @@ -472,7 +479,7 @@ def normalize_trade_state(state): def get_runtime_trend_universe(state): return ts_get_runtime_trend_universe( state, - trend_universe=TREND_UNIVERSE, + trend_universe=resolve_trend_universe(TREND_UNIVERSE), retired_positions_key=RETIRED_TREND_POSITIONS_KEY, ) @@ -481,7 +488,7 @@ def get_symbol_trade_state(state, symbol): return ts_get_symbol_trade_state( state, symbol, - trend_universe=TREND_UNIVERSE, + trend_universe=resolve_trend_universe(TREND_UNIVERSE), retired_positions_key=RETIRED_TREND_POSITIONS_KEY, ) @@ -491,7 +498,7 @@ def set_symbol_trade_state(state, symbol, symbol_state): state, symbol, symbol_state, - trend_universe=TREND_UNIVERSE, + trend_universe=resolve_trend_universe(TREND_UNIVERSE), retired_positions_key=RETIRED_TREND_POSITIONS_KEY, ) @@ -786,7 +793,7 @@ def enrich_btc_snapshot_with_cycle_indicators(btc_snapshot: dict, log_buffer: li def resolve_runtime_trend_indicators(runtime): return infra_resolve_runtime_trend_indicators( runtime, - TREND_UNIVERSE, + resolve_trend_universe(TREND_UNIVERSE), fetch_daily_indicators_fn=fetch_daily_indicators, ) @@ -855,11 +862,18 @@ def build_live_runtime(now_utc=None, *, retain_interval_receipts=False): return runtime -def _set_runtime_trend_universe(resolved_trend_universe): +def _module_set_trend_universe(resolved_trend_universe): global TREND_UNIVERSE TREND_UNIVERSE = resolved_trend_universe +def _set_runtime_trend_universe(resolved_trend_universe): + set_active_trend_universe( + resolved_trend_universe, + fallback_setter=_module_set_trend_universe, + ) + + def _ensure_runtime_client(runtime, report): return infra_ensure_runtime_client( runtime, @@ -1269,7 +1283,7 @@ def _execute_trend_rotation( execute_trend_sells=_execute_trend_sells, execute_trend_buys=_execute_trend_buys, append_trend_symbol_status=_append_trend_symbol_status, - official_trend_pool_symbols=list(TREND_UNIVERSE.keys()), + official_trend_pool_symbols=list(resolve_trend_universe(TREND_UNIVERSE).keys()), ) @@ -1316,9 +1330,12 @@ def _execute_btc_dca_cycle( def execute_cycle(runtime): - global TREND_UNIVERSE - previous_trend_universe = {symbol: meta.copy() for symbol, meta in TREND_UNIVERSE.items()} - + # B09: cycle-scoped universe on RunContext — do not mutate module TREND_UNIVERSE. + ctx = RunContext( + trend_universe={symbol: meta.copy() for symbol, meta in TREND_UNIVERSE.items()}, + ) + runtime.run_context = ctx + token = bind_run_context(ctx) try: return execute_strategy_cycle( runtime, @@ -1347,7 +1364,7 @@ def execute_cycle(runtime): traceback_module=traceback, ) finally: - TREND_UNIVERSE = previous_trend_universe + reset_run_context(token) def main(): diff --git a/run_context.py b/run_context.py new file mode 100644 index 00000000..7ac5ca48 --- /dev/null +++ b/run_context.py @@ -0,0 +1,59 @@ +"""Per-cycle RunContext for BinancePlatform (B09). + +First slice: trend universe leaves module globals during execute_cycle. +Strategy-runtime activation stays on module globals until a follow-up PR. +Does not change order-submission or state-owner semantics in runtime_support. +""" + +from __future__ import annotations + +from contextvars import ContextVar, Token +from dataclasses import dataclass +from typing import Any, Callable, Mapping, MutableMapping, Optional + + +@dataclass +class RunContext: + """Cycle-scoped mutable view. Not a replacement for ExecutionRuntime.""" + + trend_universe: MutableMapping[str, dict[str, Any]] + strategy_runtime: Any | None = None # reserved for B09 phase 2 + + +_ACTIVE_RUN_CONTEXT: ContextVar[Optional[RunContext]] = ContextVar( + "binance_active_run_context", + default=None, +) + + +def bind_run_context(ctx: RunContext) -> Token: + return _ACTIVE_RUN_CONTEXT.set(ctx) + + +def reset_run_context(token: Token) -> None: + _ACTIVE_RUN_CONTEXT.reset(token) + + +def get_active_run_context() -> Optional[RunContext]: + return _ACTIVE_RUN_CONTEXT.get() + + +def resolve_trend_universe( + fallback: Mapping[str, dict[str, Any]], +) -> Mapping[str, dict[str, Any]]: + ctx = _ACTIVE_RUN_CONTEXT.get() + if ctx is not None: + return ctx.trend_universe + return fallback + + +def set_active_trend_universe( + resolved: MutableMapping[str, dict[str, Any]], + *, + fallback_setter: Callable[[MutableMapping[str, dict[str, Any]]], None], +) -> None: + ctx = _ACTIVE_RUN_CONTEXT.get() + if ctx is not None: + ctx.trend_universe = resolved + return + fallback_setter(resolved) diff --git a/runtime_support.py b/runtime_support.py index 636c9156..db59531b 100644 --- a/runtime_support.py +++ b/runtime_support.py @@ -320,6 +320,7 @@ class ExecutionRuntime: fuel_symbol: str = "BNBUSDT" fuel_asset: str = "BNB" side_effect_log: list[dict[str, Any]] = field(default_factory=list) + run_context: Any = None def __post_init__(self): if self.now_utc is None: diff --git a/tests/test_run_context.py b/tests/test_run_context.py new file mode 100644 index 00000000..f90871a6 --- /dev/null +++ b/tests/test_run_context.py @@ -0,0 +1,133 @@ +"""B09: RunContext isolates trend universe from module globals.""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +import main +from run_context import ( + RunContext, + bind_run_context, + get_active_run_context, + reset_run_context, + resolve_trend_universe, + set_active_trend_universe, +) +from runtime_support import ExecutionRuntime + + +def _baseline_universe(): + return {symbol: meta.copy() for symbol, meta in main.STATIC_TREND_UNIVERSE.items()} + + +class RunContextUnitTests(unittest.TestCase): + def setUp(self): + main.TREND_UNIVERSE = _baseline_universe() + + def tearDown(self): + main.TREND_UNIVERSE = _baseline_universe() + self.assertIsNone( + get_active_run_context(), + "active RunContext leaked after test", + ) + + def test_resolve_falls_back_to_module_when_unbound(self): + self.assertIs(resolve_trend_universe(main.TREND_UNIVERSE), main.TREND_UNIVERSE) + + def test_set_and_resolve_use_active_context(self): + custom = {"ZZZTESTUSDT": {"base_asset": "ZZZ"}} + ctx = RunContext(trend_universe=_baseline_universe()) + token = bind_run_context(ctx) + try: + set_active_trend_universe( + custom, + fallback_setter=lambda _value: (_ for _ in ()).throw( + AssertionError("fallback must not run while bound") + ), + ) + self.assertEqual(dict(resolve_trend_universe(main.TREND_UNIVERSE)), custom) + self.assertEqual(dict(main.TREND_UNIVERSE), _baseline_universe()) + finally: + reset_run_context(token) + self.assertEqual(dict(main.TREND_UNIVERSE), _baseline_universe()) + self.assertIs(resolve_trend_universe(main.TREND_UNIVERSE), main.TREND_UNIVERSE) + + def test_sequential_binds_do_not_leak_universe(self): + first = {"AAAISOLATEUSDT": {"base_asset": "AAA"}} + second = {"BBBISOLATEUSDT": {"base_asset": "BBB"}} + + ctx1 = RunContext(trend_universe=_baseline_universe()) + token1 = bind_run_context(ctx1) + try: + set_active_trend_universe(first, fallback_setter=main._module_set_trend_universe) + self.assertEqual(dict(resolve_trend_universe(main.TREND_UNIVERSE)), first) + finally: + reset_run_context(token1) + + ctx2 = RunContext(trend_universe=_baseline_universe()) + token2 = bind_run_context(ctx2) + try: + resolved = resolve_trend_universe(main.TREND_UNIVERSE) + self.assertNotIn("AAAISOLATEUSDT", resolved) + set_active_trend_universe(second, fallback_setter=main._module_set_trend_universe) + self.assertEqual(dict(resolve_trend_universe(main.TREND_UNIVERSE)), second) + self.assertNotIn("AAAISOLATEUSDT", resolve_trend_universe(main.TREND_UNIVERSE)) + finally: + reset_run_context(token2) + + self.assertEqual(dict(main.TREND_UNIVERSE), _baseline_universe()) + + +class ExecuteCycleUniverseIsolationTests(unittest.TestCase): + def setUp(self): + self._baseline = _baseline_universe() + main.TREND_UNIVERSE = _baseline_universe() + + def tearDown(self): + main.TREND_UNIVERSE = _baseline_universe() + self.assertIsNone(get_active_run_context()) + + def test_execute_cycle_does_not_mutate_module_universe_on_success(self): + injected = {"ZZZTESTUSDT": {"base_asset": "ZZZ"}} + + def fake_cycle(runtime, **_kwargs): + self.assertIsNotNone(runtime.run_context) + main._set_runtime_trend_universe(injected) + self.assertEqual(dict(resolve_trend_universe(main.TREND_UNIVERSE)), injected) + self.assertEqual(dict(main.TREND_UNIVERSE), self._baseline) + return {"status": "ok"} + + runtime = ExecutionRuntime(dry_run=True, strategy_profile="crypto_live_pool_rotation") + with patch("main.execute_strategy_cycle", side_effect=fake_cycle): + report = main.execute_cycle(runtime) + self.assertEqual(report["status"], "ok") + self.assertEqual(dict(main.TREND_UNIVERSE), self._baseline) + self.assertIsNone(get_active_run_context()) + + def test_execute_cycle_does_not_mutate_module_universe_on_error(self): + injected = {"ZZZTESTUSDT": {"base_asset": "ZZZ"}} + + def fake_cycle(runtime, **_kwargs): + main._set_runtime_trend_universe(injected) + raise RuntimeError("boom") + + runtime = ExecutionRuntime(dry_run=True, strategy_profile="crypto_live_pool_rotation") + with patch("main.execute_strategy_cycle", side_effect=fake_cycle): + with self.assertRaises(RuntimeError): + main.execute_cycle(runtime) + self.assertEqual(dict(main.TREND_UNIVERSE), self._baseline) + self.assertIsNone(get_active_run_context()) + + def test_unbound_setter_still_updates_module_for_compat(self): + custom = {"ZZZTESTUSDT": {"base_asset": "ZZZ"}} + self.assertIsNone(get_active_run_context()) + main._set_runtime_trend_universe(custom) + try: + self.assertEqual(dict(main.TREND_UNIVERSE), custom) + finally: + main.TREND_UNIVERSE = _baseline_universe() + + +if __name__ == "__main__": + unittest.main() From 077a64c24ee6c8a5e259c9e4fde3bb371ae7a7f9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:57:35 +0800 Subject: [PATCH 42/47] refactor(b09): mount STRATEGY_RUNTIME onto RunContext during cycles (#360) PR-2 of B09: activate still writes module STRATEGY_RUNTIME for unbound fallback/compat, and also stores the handle on ExecutionRuntime plus active RunContext. Cycle-path evaluate/metrics/artifact/display reads go through resolve_strategy_runtime. No submission/owner changes. --- main.py | 53 ++++++++++----- run_context.py | 27 +++++++- runtime_support.py | 1 + tests/test_run_context.py | 134 +++++++++++++++++++++++++++++++++++++- 4 files changed, 196 insertions(+), 19 deletions(-) diff --git a/main.py b/main.py index 682d8360..523388e6 100644 --- a/main.py +++ b/main.py @@ -104,7 +104,9 @@ from run_context import ( RunContext, bind_run_context, + get_active_run_context, reset_run_context, + resolve_strategy_runtime, resolve_trend_universe, set_active_trend_universe, ) @@ -263,8 +265,12 @@ def _load_import_safe_strategy_runtime(): BTC_MARKET_SNAPSHOT_RETRY_DELAYS = (5, 15) -def _activate_execution_strategy_runtime(profile: str, *, runtime_target=None) -> None: - """Replace the import-safe evaluator only after RuntimeTarget validation.""" +def _activate_execution_strategy_runtime(profile: str, *, runtime_target=None): + """Replace the import-safe evaluator only after RuntimeTarget validation. + + Writes the activated handle to module STRATEGY_RUNTIME (compat / unbound + fallback) and, when a RunContext is bound, also to ctx.strategy_runtime. + """ global STRATEGY_RUNTIME global TREND_POOL_SIZE @@ -272,12 +278,20 @@ def _activate_execution_strategy_runtime(profile: str, *, runtime_target=None) - global DEFAULT_TREND_POOL_MAX_AGE_DAYS global DEFAULT_TREND_POOL_ACCEPTABLE_MODES - STRATEGY_RUNTIME = (load_strategy_runtime(profile) if runtime_target is None - else load_strategy_runtime(profile, runtime_target=runtime_target)) - TREND_POOL_SIZE = STRATEGY_RUNTIME.trend_pool_size - DEFAULT_LIVE_POOL_LEGACY_PATH = STRATEGY_RUNTIME.default_local_artifact_path - DEFAULT_TREND_POOL_MAX_AGE_DAYS = int(STRATEGY_RUNTIME.artifact_contract["max_age_days"]) - DEFAULT_TREND_POOL_ACCEPTABLE_MODES = tuple(STRATEGY_RUNTIME.artifact_contract["acceptable_modes"]) + activated = ( + load_strategy_runtime(profile) + if runtime_target is None + else load_strategy_runtime(profile, runtime_target=runtime_target) + ) + STRATEGY_RUNTIME = activated + TREND_POOL_SIZE = activated.trend_pool_size + DEFAULT_LIVE_POOL_LEGACY_PATH = activated.default_local_artifact_path + DEFAULT_TREND_POOL_MAX_AGE_DAYS = int(activated.artifact_contract["max_age_days"]) + DEFAULT_TREND_POOL_ACCEPTABLE_MODES = tuple(activated.artifact_contract["acceptable_modes"]) + ctx = get_active_run_context() + if ctx is not None: + ctx.strategy_runtime = activated + return activated class BalanceFetchError(RuntimeError): @@ -362,7 +376,8 @@ def validate_trend_pool_payload( def get_default_live_pool_candidates(): - return list(STRATEGY_RUNTIME.local_artifact_candidates) or tp_get_default_live_pool_candidates( + strategy_runtime = resolve_strategy_runtime(STRATEGY_RUNTIME) + return list(strategy_runtime.local_artifact_candidates) or tp_get_default_live_pool_candidates( DEFAULT_LIVE_POOL_LEGACY_PATH ) @@ -658,9 +673,10 @@ def maybe_send_periodic_btc_status_report( strategy_display_name=None, notifier_fn=None, ): - runtime_manifest = getattr(getattr(STRATEGY_RUNTIME, "entrypoint", None), "manifest", None) + strategy_runtime = resolve_strategy_runtime(STRATEGY_RUNTIME) + runtime_manifest = getattr(getattr(strategy_runtime, "entrypoint", None), "manifest", None) resolved_strategy_display_name = strategy_display_name or build_strategy_display_name(t)( - getattr(STRATEGY_RUNTIME, "profile", "crypto_live_pool_rotation"), + getattr(strategy_runtime, "profile", "crypto_live_pool_rotation"), fallback_name=getattr(runtime_manifest, "display_name", "Crypto Live Pool Rotation"), ) return report_maybe_send_periodic_btc_status_report( @@ -850,7 +866,10 @@ def build_live_runtime(now_utc=None, *, retain_interval_receipts=False): state_writer=set_trade_state, notifier=lambda **kwargs: send_tg_msg(kwargs["token"], kwargs["chat_id"], kwargs["text"]), ) - _activate_execution_strategy_runtime(runtime.strategy_profile, runtime_target=runtime.runtime_target) + runtime.strategy_runtime = _activate_execution_strategy_runtime( + runtime.strategy_profile, + runtime_target=runtime.runtime_target, + ) if not runtime.dry_run and runtime.standard_execution_permitted: runtime.bound_state_access = bind_trade_state_access( normalize_fn=normalize_trade_state, default_state_factory=build_default_state, @@ -963,14 +982,15 @@ def _resolve_strategy_evaluation( for symbol, meta in runtime_trend_universe.items() if not meta.get("valuation_only") ) - account_metrics = STRATEGY_RUNTIME.compute_account_metrics( + strategy_runtime = resolve_strategy_runtime(STRATEGY_RUNTIME) + account_metrics = strategy_runtime.compute_account_metrics( runtime_trend_universe, balances, prices, u_total, fuel_val, ) - return STRATEGY_RUNTIME.evaluate( + return strategy_runtime.evaluate( prices=prices, trend_indicators=trend_indicators, btc_snapshot=btc_snapshot, @@ -1330,9 +1350,12 @@ def _execute_btc_dca_cycle( def execute_cycle(runtime): - # B09: cycle-scoped universe on RunContext — do not mutate module TREND_UNIVERSE. + # B09: cycle-scoped universe + strategy handle on RunContext. + # Do not mutate module TREND_UNIVERSE; STRATEGY_RUNTIME stays as unbound fallback. + mounted_strategy = getattr(runtime, "strategy_runtime", None) or STRATEGY_RUNTIME ctx = RunContext( trend_universe={symbol: meta.copy() for symbol, meta in TREND_UNIVERSE.items()}, + strategy_runtime=mounted_strategy, ) runtime.run_context = ctx token = bind_run_context(ctx) diff --git a/run_context.py b/run_context.py index 7ac5ca48..42a92dbc 100644 --- a/run_context.py +++ b/run_context.py @@ -1,7 +1,8 @@ """Per-cycle RunContext for BinancePlatform (B09). -First slice: trend universe leaves module globals during execute_cycle. -Strategy-runtime activation stays on module globals until a follow-up PR. +Trend universe (PR-1) and strategy-runtime handle (PR-2) bind to a cycle-scoped +context during execute_cycle. Module STRATEGY_RUNTIME / TREND_UNIVERSE remain +import-safe fallbacks and out-of-cycle patch points. Does not change order-submission or state-owner semantics in runtime_support. """ @@ -17,7 +18,7 @@ class RunContext: """Cycle-scoped mutable view. Not a replacement for ExecutionRuntime.""" trend_universe: MutableMapping[str, dict[str, Any]] - strategy_runtime: Any | None = None # reserved for B09 phase 2 + strategy_runtime: Any | None = None _ACTIVE_RUN_CONTEXT: ContextVar[Optional[RunContext]] = ContextVar( @@ -57,3 +58,23 @@ def set_active_trend_universe( ctx.trend_universe = resolved return fallback_setter(resolved) + + +def resolve_strategy_runtime(fallback: Any) -> Any: + """Prefer cycle-bound strategy runtime; else module/import-safe fallback.""" + ctx = _ACTIVE_RUN_CONTEXT.get() + if ctx is not None and ctx.strategy_runtime is not None: + return ctx.strategy_runtime + return fallback + + +def set_active_strategy_runtime( + activated: Any, + *, + fallback_setter: Callable[[Any], None], +) -> None: + ctx = _ACTIVE_RUN_CONTEXT.get() + if ctx is not None: + ctx.strategy_runtime = activated + return + fallback_setter(activated) diff --git a/runtime_support.py b/runtime_support.py index db59531b..8d17fcf1 100644 --- a/runtime_support.py +++ b/runtime_support.py @@ -321,6 +321,7 @@ class ExecutionRuntime: fuel_asset: str = "BNB" side_effect_log: list[dict[str, Any]] = field(default_factory=list) run_context: Any = None + strategy_runtime: Any = None # B09 PR-2: activated handle; cycle mounts onto RunContext def __post_init__(self): if self.now_utc is None: diff --git a/tests/test_run_context.py b/tests/test_run_context.py index f90871a6..78bf6c35 100644 --- a/tests/test_run_context.py +++ b/tests/test_run_context.py @@ -1,8 +1,9 @@ -"""B09: RunContext isolates trend universe from module globals.""" +"""B09: RunContext isolates trend universe and strategy runtime from module globals.""" from __future__ import annotations import unittest +from types import SimpleNamespace from unittest.mock import patch import main @@ -11,6 +12,7 @@ bind_run_context, get_active_run_context, reset_run_context, + resolve_strategy_runtime, resolve_trend_universe, set_active_trend_universe, ) @@ -34,6 +36,7 @@ def tearDown(self): def test_resolve_falls_back_to_module_when_unbound(self): self.assertIs(resolve_trend_universe(main.TREND_UNIVERSE), main.TREND_UNIVERSE) + self.assertIs(resolve_strategy_runtime(main.STRATEGY_RUNTIME), main.STRATEGY_RUNTIME) def test_set_and_resolve_use_active_context(self): custom = {"ZZZTESTUSDT": {"base_asset": "ZZZ"}} @@ -78,6 +81,40 @@ def test_sequential_binds_do_not_leak_universe(self): self.assertEqual(dict(main.TREND_UNIVERSE), _baseline_universe()) + def test_resolve_strategy_runtime_prefers_active_context(self): + module_handle = main.STRATEGY_RUNTIME + bound_handle = SimpleNamespace(profile="bound-profile", marker="ctx") + ctx = RunContext(trend_universe=_baseline_universe(), strategy_runtime=bound_handle) + token = bind_run_context(ctx) + try: + self.assertIs(resolve_strategy_runtime(module_handle), bound_handle) + self.assertIs(main.STRATEGY_RUNTIME, module_handle) + finally: + reset_run_context(token) + self.assertIs(resolve_strategy_runtime(module_handle), module_handle) + + def test_sequential_binds_do_not_leak_strategy_runtime(self): + first = SimpleNamespace(profile="first") + second = SimpleNamespace(profile="second") + module_handle = main.STRATEGY_RUNTIME + + ctx1 = RunContext(trend_universe=_baseline_universe(), strategy_runtime=first) + token1 = bind_run_context(ctx1) + try: + self.assertIs(resolve_strategy_runtime(module_handle), first) + finally: + reset_run_context(token1) + + ctx2 = RunContext(trend_universe=_baseline_universe(), strategy_runtime=second) + token2 = bind_run_context(ctx2) + try: + self.assertIs(resolve_strategy_runtime(module_handle), second) + self.assertIsNot(resolve_strategy_runtime(module_handle), first) + finally: + reset_run_context(token2) + + self.assertIs(resolve_strategy_runtime(module_handle), module_handle) + class ExecuteCycleUniverseIsolationTests(unittest.TestCase): def setUp(self): @@ -128,6 +165,101 @@ def test_unbound_setter_still_updates_module_for_compat(self): finally: main.TREND_UNIVERSE = _baseline_universe() + def test_execute_cycle_mounts_runtime_strategy_handle(self): + mounted = SimpleNamespace(profile="mounted-from-runtime", marker="rt") + + def fake_cycle(runtime, **_kwargs): + self.assertIs(runtime.run_context.strategy_runtime, mounted) + self.assertIs(resolve_strategy_runtime(main.STRATEGY_RUNTIME), mounted) + return {"status": "ok"} + + runtime = ExecutionRuntime( + dry_run=True, + strategy_profile="crypto_live_pool_rotation", + strategy_runtime=mounted, + ) + with patch("main.execute_strategy_cycle", side_effect=fake_cycle): + report = main.execute_cycle(runtime) + self.assertEqual(report["status"], "ok") + self.assertIsNone(get_active_run_context()) + + def test_activate_while_bound_writes_context_and_module(self): + prior = main.STRATEGY_RUNTIME + prior_pool = main.TREND_POOL_SIZE + prior_legacy = main.DEFAULT_LIVE_POOL_LEGACY_PATH + prior_max_age = main.DEFAULT_TREND_POOL_MAX_AGE_DAYS + prior_modes = main.DEFAULT_TREND_POOL_ACCEPTABLE_MODES + fake = SimpleNamespace( + profile="crypto_live_pool_rotation", + trend_pool_size=11, + default_local_artifact_path=prior.default_local_artifact_path, + artifact_contract={ + "max_age_days": int(prior.artifact_contract["max_age_days"]), + "acceptable_modes": tuple(prior.artifact_contract["acceptable_modes"]), + }, + local_artifact_candidates=(), + ) + ctx = RunContext(trend_universe=_baseline_universe()) + token = bind_run_context(ctx) + try: + with patch("main.load_strategy_runtime", return_value=fake) as loader: + activated = main._activate_execution_strategy_runtime("crypto_live_pool_rotation") + loader.assert_called_once_with("crypto_live_pool_rotation") + self.assertIs(activated, fake) + self.assertIs(ctx.strategy_runtime, fake) + self.assertIs(main.STRATEGY_RUNTIME, fake) + self.assertIs(resolve_strategy_runtime(prior), fake) + self.assertEqual(main.TREND_POOL_SIZE, 11) + finally: + reset_run_context(token) + main.STRATEGY_RUNTIME = prior + main.TREND_POOL_SIZE = prior_pool + main.DEFAULT_LIVE_POOL_LEGACY_PATH = prior_legacy + main.DEFAULT_TREND_POOL_MAX_AGE_DAYS = prior_max_age + main.DEFAULT_TREND_POOL_ACCEPTABLE_MODES = prior_modes + + +class StrategyRuntimeEquivalenceTests(unittest.TestCase): + """Bound resolve path must match direct module handle for a frozen profile.""" + + def tearDown(self): + self.assertIsNone(get_active_run_context()) + + def test_bound_resolve_matches_module_metrics_for_same_handle(self): + # Use the import-safe module handle (already loaded) — behavior-equivalent + # mount onto RunContext without requiring full CryptoStrategies catalog. + activated = main.STRATEGY_RUNTIME + universe = { + "ETHUSDT": {"base_asset": "ETH"}, + "SOLUSDT": {"base_asset": "SOL", "valuation_only": True}, + } + prices = {"BTCUSDT": 1.0, "ETHUSDT": 1.0, "SOLUSDT": 1.0, "BNBUSDT": 1.0} + balances = {"BTCUSDT": 0.0, "ETHUSDT": 0.0, "SOLUSDT": 0.0, "BNBUSDT": 0.0} + u_total = 100.0 + fuel_val = 0.0 + + direct_metrics = activated.compute_account_metrics( + universe, balances, prices, u_total, fuel_val + ) + + ctx = RunContext( + trend_universe={symbol: meta.copy() for symbol, meta in universe.items()}, + strategy_runtime=activated, + ) + token = bind_run_context(ctx) + try: + resolved = resolve_strategy_runtime(SimpleNamespace(profile="must-not-use")) + self.assertIs(resolved, activated) + bound_metrics = resolved.compute_account_metrics( + universe, balances, prices, u_total, fuel_val + ) + finally: + reset_run_context(token) + + self.assertEqual(bound_metrics, direct_metrics) + self.assertTrue(callable(activated.evaluate)) + self.assertTrue(callable(resolve_strategy_runtime(activated).evaluate)) + if __name__ == "__main__": unittest.main() From b5226b8dfb4cb7b2585d4131aabc5d248a2138b8 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:01:11 +0800 Subject: [PATCH 43/47] refactor(b09): drop bound-cycle STRATEGY_RUNTIME module fallback (#361) PR-3: while a RunContext is bound, resolve_strategy_runtime requires ctx.strategy_runtime and never consults module STRATEGY_RUNTIME. Activate during a bound cycle mounts on context only (no module leak); unbound activate still updates the module for compat. Tests poison the module during cycles to prove isolation. B04 submission/owner untouched. --- main.py | 16 +++++-- run_context.py | 25 +++++++--- tests/test_run_context.py | 98 +++++++++++++++++++++++++++++++++++---- 3 files changed, 119 insertions(+), 20 deletions(-) diff --git a/main.py b/main.py index 523388e6..3b35661e 100644 --- a/main.py +++ b/main.py @@ -268,8 +268,10 @@ def _load_import_safe_strategy_runtime(): def _activate_execution_strategy_runtime(profile: str, *, runtime_target=None): """Replace the import-safe evaluator only after RuntimeTarget validation. - Writes the activated handle to module STRATEGY_RUNTIME (compat / unbound - fallback) and, when a RunContext is bound, also to ctx.strategy_runtime. + When a RunContext is bound (cycle path), mount the handle on the context only + — do not rewrite module STRATEGY_RUNTIME (no cycle→module leakage). Unbound + callers still update the module symbol for out-of-cycle compat / patches. + Pool-derived knobs (size / artifact paths) still refresh either way. """ global STRATEGY_RUNTIME @@ -283,7 +285,6 @@ def _activate_execution_strategy_runtime(profile: str, *, runtime_target=None): if runtime_target is None else load_strategy_runtime(profile, runtime_target=runtime_target) ) - STRATEGY_RUNTIME = activated TREND_POOL_SIZE = activated.trend_pool_size DEFAULT_LIVE_POOL_LEGACY_PATH = activated.default_local_artifact_path DEFAULT_TREND_POOL_MAX_AGE_DAYS = int(activated.artifact_contract["max_age_days"]) @@ -291,6 +292,8 @@ def _activate_execution_strategy_runtime(profile: str, *, runtime_target=None): ctx = get_active_run_context() if ctx is not None: ctx.strategy_runtime = activated + return activated + STRATEGY_RUNTIME = activated return activated @@ -1350,9 +1353,12 @@ def _execute_btc_dca_cycle( def execute_cycle(runtime): - # B09: cycle-scoped universe + strategy handle on RunContext. - # Do not mutate module TREND_UNIVERSE; STRATEGY_RUNTIME stays as unbound fallback. + # B09 PR-3: cycle-scoped universe + strategy handle on RunContext. + # Bind-time seed may copy module defaults once; while bound, resolves never + # fall back to module STRATEGY_RUNTIME / TREND_UNIVERSE. mounted_strategy = getattr(runtime, "strategy_runtime", None) or STRATEGY_RUNTIME + if mounted_strategy is None: + raise RuntimeError("execute_cycle_missing_strategy_runtime") ctx = RunContext( trend_universe={symbol: meta.copy() for symbol, meta in TREND_UNIVERSE.items()}, strategy_runtime=mounted_strategy, diff --git a/run_context.py b/run_context.py index 42a92dbc..737a0acd 100644 --- a/run_context.py +++ b/run_context.py @@ -1,8 +1,9 @@ """Per-cycle RunContext for BinancePlatform (B09). -Trend universe (PR-1) and strategy-runtime handle (PR-2) bind to a cycle-scoped -context during execute_cycle. Module STRATEGY_RUNTIME / TREND_UNIVERSE remain -import-safe fallbacks and out-of-cycle patch points. +Trend universe and strategy-runtime bind to a cycle-scoped context during +execute_cycle. While a RunContext is bound, resolves do not fall back to module +STRATEGY_RUNTIME / TREND_UNIVERSE. Module symbols remain import-safe seeds and +out-of-cycle patch points only. Does not change order-submission or state-owner semantics in runtime_support. """ @@ -27,6 +28,10 @@ class RunContext: ) +class RunContextError(RuntimeError): + """Fail-closed when a bound cycle is missing a required RunContext field.""" + + def bind_run_context(ctx: RunContext) -> Token: return _ACTIVE_RUN_CONTEXT.set(ctx) @@ -40,11 +45,13 @@ def get_active_run_context() -> Optional[RunContext]: def resolve_trend_universe( - fallback: Mapping[str, dict[str, Any]], + fallback: Mapping[str, dict[str, Any]] | None = None, ) -> Mapping[str, dict[str, Any]]: ctx = _ACTIVE_RUN_CONTEXT.get() if ctx is not None: return ctx.trend_universe + if fallback is None: + raise RunContextError("trend_universe_unbound_requires_fallback") return fallback @@ -60,11 +67,15 @@ def set_active_trend_universe( fallback_setter(resolved) -def resolve_strategy_runtime(fallback: Any) -> Any: - """Prefer cycle-bound strategy runtime; else module/import-safe fallback.""" +def resolve_strategy_runtime(fallback: Any = None) -> Any: + """Bound cycles must use ctx.strategy_runtime; unbound may use module fallback.""" ctx = _ACTIVE_RUN_CONTEXT.get() - if ctx is not None and ctx.strategy_runtime is not None: + if ctx is not None: + if ctx.strategy_runtime is None: + raise RunContextError("run_context_missing_strategy_runtime") return ctx.strategy_runtime + if fallback is None: + raise RunContextError("strategy_runtime_unbound_requires_fallback") return fallback diff --git a/tests/test_run_context.py b/tests/test_run_context.py index 78bf6c35..d8ac26f6 100644 --- a/tests/test_run_context.py +++ b/tests/test_run_context.py @@ -9,6 +9,7 @@ import main from run_context import ( RunContext, + RunContextError, bind_run_context, get_active_run_context, reset_run_context, @@ -38,6 +39,12 @@ def test_resolve_falls_back_to_module_when_unbound(self): self.assertIs(resolve_trend_universe(main.TREND_UNIVERSE), main.TREND_UNIVERSE) self.assertIs(resolve_strategy_runtime(main.STRATEGY_RUNTIME), main.STRATEGY_RUNTIME) + def test_unbound_resolve_without_fallback_fails_closed(self): + with self.assertRaises(RunContextError): + resolve_strategy_runtime() + with self.assertRaises(RunContextError): + resolve_trend_universe() + def test_set_and_resolve_use_active_context(self): custom = {"ZZZTESTUSDT": {"base_asset": "ZZZ"}} ctx = RunContext(trend_universe=_baseline_universe()) @@ -81,18 +88,33 @@ def test_sequential_binds_do_not_leak_universe(self): self.assertEqual(dict(main.TREND_UNIVERSE), _baseline_universe()) - def test_resolve_strategy_runtime_prefers_active_context(self): + def test_bound_resolve_ignores_poisoned_module_strategy_runtime(self): + """PR-3: while bound, module STRATEGY_RUNTIME must not be consulted.""" module_handle = main.STRATEGY_RUNTIME bound_handle = SimpleNamespace(profile="bound-profile", marker="ctx") + poison = SimpleNamespace(profile="poison-module", marker="module") ctx = RunContext(trend_universe=_baseline_universe(), strategy_runtime=bound_handle) token = bind_run_context(ctx) try: + main.STRATEGY_RUNTIME = poison + # Fallback arg is also poisoned — bound path must ignore both. + self.assertIs(resolve_strategy_runtime(poison), bound_handle) self.assertIs(resolve_strategy_runtime(module_handle), bound_handle) - self.assertIs(main.STRATEGY_RUNTIME, module_handle) + self.assertIs(main.STRATEGY_RUNTIME, poison) finally: reset_run_context(token) + main.STRATEGY_RUNTIME = module_handle self.assertIs(resolve_strategy_runtime(module_handle), module_handle) + def test_bound_without_strategy_runtime_fails_closed(self): + ctx = RunContext(trend_universe=_baseline_universe(), strategy_runtime=None) + token = bind_run_context(ctx) + try: + with self.assertRaises(RunContextError): + resolve_strategy_runtime(main.STRATEGY_RUNTIME) + finally: + reset_run_context(token) + def test_sequential_binds_do_not_leak_strategy_runtime(self): first = SimpleNamespace(profile="first") second = SimpleNamespace(profile="second") @@ -183,7 +205,36 @@ def fake_cycle(runtime, **_kwargs): self.assertEqual(report["status"], "ok") self.assertIsNone(get_active_run_context()) - def test_activate_while_bound_writes_context_and_module(self): + def test_execute_cycle_ignores_module_strategy_poison_and_does_not_leak(self): + """PR-3: cycle resolve ignores module poison; module STRATEGY_RUNTIME unchanged.""" + prior = main.STRATEGY_RUNTIME + mounted = SimpleNamespace(profile="cycle-mounted", marker="rt") + poison = SimpleNamespace(profile="poison", marker="module") + + def fake_cycle(runtime, **_kwargs): + main.STRATEGY_RUNTIME = poison + self.assertIs(resolve_strategy_runtime(poison), mounted) + self.assertIs(runtime.run_context.strategy_runtime, mounted) + return {"status": "ok"} + + runtime = ExecutionRuntime( + dry_run=True, + strategy_profile="crypto_live_pool_rotation", + strategy_runtime=mounted, + ) + try: + with patch("main.execute_strategy_cycle", side_effect=fake_cycle): + report = main.execute_cycle(runtime) + self.assertEqual(report["status"], "ok") + # Poison applied inside cycle must not be "restored over" — we prove + # cycle itself did not write module STRATEGY_RUNTIME; caller poison remains. + self.assertIs(main.STRATEGY_RUNTIME, poison) + finally: + main.STRATEGY_RUNTIME = prior + self.assertIsNone(get_active_run_context()) + + def test_activate_while_bound_writes_context_not_module(self): + """PR-3: activate during a bound cycle must not leak into module STRATEGY_RUNTIME.""" prior = main.STRATEGY_RUNTIME prior_pool = main.TREND_POOL_SIZE prior_legacy = main.DEFAULT_LIVE_POOL_LEGACY_PATH @@ -199,7 +250,10 @@ def test_activate_while_bound_writes_context_and_module(self): }, local_artifact_candidates=(), ) - ctx = RunContext(trend_universe=_baseline_universe()) + ctx = RunContext( + trend_universe=_baseline_universe(), + strategy_runtime=SimpleNamespace(profile="seed"), + ) token = bind_run_context(ctx) try: with patch("main.load_strategy_runtime", return_value=fake) as loader: @@ -207,7 +261,7 @@ def test_activate_while_bound_writes_context_and_module(self): loader.assert_called_once_with("crypto_live_pool_rotation") self.assertIs(activated, fake) self.assertIs(ctx.strategy_runtime, fake) - self.assertIs(main.STRATEGY_RUNTIME, fake) + self.assertIs(main.STRATEGY_RUNTIME, prior) # no module leakage self.assertIs(resolve_strategy_runtime(prior), fake) self.assertEqual(main.TREND_POOL_SIZE, 11) finally: @@ -218,16 +272,44 @@ def test_activate_while_bound_writes_context_and_module(self): main.DEFAULT_TREND_POOL_MAX_AGE_DAYS = prior_max_age main.DEFAULT_TREND_POOL_ACCEPTABLE_MODES = prior_modes + def test_activate_unbound_still_updates_module_for_compat(self): + prior = main.STRATEGY_RUNTIME + prior_pool = main.TREND_POOL_SIZE + prior_legacy = main.DEFAULT_LIVE_POOL_LEGACY_PATH + prior_max_age = main.DEFAULT_TREND_POOL_MAX_AGE_DAYS + prior_modes = main.DEFAULT_TREND_POOL_ACCEPTABLE_MODES + fake = SimpleNamespace( + profile="unbound-activate", + trend_pool_size=7, + default_local_artifact_path=prior.default_local_artifact_path, + artifact_contract={ + "max_age_days": int(prior.artifact_contract["max_age_days"]), + "acceptable_modes": tuple(prior.artifact_contract["acceptable_modes"]), + }, + local_artifact_candidates=(), + ) + self.assertIsNone(get_active_run_context()) + try: + with patch("main.load_strategy_runtime", return_value=fake): + activated = main._activate_execution_strategy_runtime("crypto_live_pool_rotation") + self.assertIs(activated, fake) + self.assertIs(main.STRATEGY_RUNTIME, fake) + self.assertEqual(main.TREND_POOL_SIZE, 7) + finally: + main.STRATEGY_RUNTIME = prior + main.TREND_POOL_SIZE = prior_pool + main.DEFAULT_LIVE_POOL_LEGACY_PATH = prior_legacy + main.DEFAULT_TREND_POOL_MAX_AGE_DAYS = prior_max_age + main.DEFAULT_TREND_POOL_ACCEPTABLE_MODES = prior_modes + class StrategyRuntimeEquivalenceTests(unittest.TestCase): - """Bound resolve path must match direct module handle for a frozen profile.""" + """Bound resolve path must match direct handle for a frozen profile.""" def tearDown(self): self.assertIsNone(get_active_run_context()) def test_bound_resolve_matches_module_metrics_for_same_handle(self): - # Use the import-safe module handle (already loaded) — behavior-equivalent - # mount onto RunContext without requiring full CryptoStrategies catalog. activated = main.STRATEGY_RUNTIME universe = { "ETHUSDT": {"base_asset": "ETH"}, From 86101d5c3ff26ef3c0ad8bbf7c380017d8fa215a Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:06:58 +0800 Subject: [PATCH 44/47] fix(ops): skip Runtime heartbeat alert when main.yml is disabled When GitHub reports the watched Runtime workflow as disabled_manually or disabled_inactivity, treat the heartbeat as not_applicable instead of alerting on an empty lookback. Matches run 37911924433 (0 strategy runs while Runtime was disabled). --- scripts/runtime_workflow_heartbeat.py | 36 ++++++++++++++++++++++++ tests/test_runtime_workflow_heartbeat.py | 24 ++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/scripts/runtime_workflow_heartbeat.py b/scripts/runtime_workflow_heartbeat.py index b4cecb44..fbad18b1 100644 --- a/scripts/runtime_workflow_heartbeat.py +++ b/scripts/runtime_workflow_heartbeat.py @@ -301,6 +301,19 @@ def _list_runtime_runs( return [run for run in runs if run.get("display_title") == "Runtime · strategy"] + +def _get_workflow_state(*, repository: str, workflow: str, token: str) -> str | None: + """Return GitHub Actions workflow state (active/disabled_manually/...), or None if unknown.""" + url = f"https://api.github.com/repos/{repository}/actions/workflows/{workflow}" + try: + payload = _github_request(url, token) + except Exception as exc: # noqa: BLE001 + print(f"Workflow state lookup skipped: {exc}", file=sys.stderr) + return None + state = payload.get("state") + return state if isinstance(state, str) and state else None + + def _run_summary(run: dict[str, Any] | None) -> dict[str, Any] | None: if run is None: return None @@ -470,6 +483,29 @@ def main() -> int: if not token: raise SystemExit("GITHUB_TOKEN is required") + workflow_state = _get_workflow_state(repository=repository, workflow=workflow, token=token) + if workflow_state in {"disabled_manually", "disabled_inactivity"}: + assessment = { + "schema": _ASSESSMENT_SCHEMA, + "observed_at": dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z"), + "status": "not_applicable", + "reason": "runtime_workflow_disabled", + "query": { + "workflow": workflow, + "branch": branch, + "workflow_state": workflow_state, + "runs_returned": 0, + }, + } + _write_assessment(assessment) + print(json.dumps(assessment, sort_keys=True)) + print( + "Runtime workflow heartbeat skipped: " + f"workflow={workflow} state={workflow_state} " + "(no strategy runs expected while disabled)" + ) + return 0 + now = dt.datetime.now(dt.timezone.utc) since = now - dt.timedelta(hours=max(lookback_hours, expected_interval_hours * (max_consecutive_misses + 1))) try: diff --git a/tests/test_runtime_workflow_heartbeat.py b/tests/test_runtime_workflow_heartbeat.py index 820f866e..bff5b748 100644 --- a/tests/test_runtime_workflow_heartbeat.py +++ b/tests/test_runtime_workflow_heartbeat.py @@ -188,6 +188,28 @@ def test_disabled_target_skips_github_api_lookup_and_writes_assessment(self) -> request.assert_not_called() + def test_disabled_runtime_workflow_skips_alert_without_failing(self) -> None: + with patch.dict( + os.environ, + { + "GITHUB_TOKEN": "token-1", + "RUNTIME_TARGET_ENABLED": "true", + "RUNTIME_HEARTBEAT_FAIL_WORKFLOW_ON_ALERT": "true", + }, + clear=True, + ): + with patch.object( + heartbeat, + "_get_workflow_state", + return_value="disabled_manually", + ) as state: + with patch.object(heartbeat, "_list_runtime_runs") as list_runs: + with patch.object(heartbeat, "_send_telegram") as send_telegram: + self.assertEqual(heartbeat.main(), 0) + state.assert_called_once() + list_runs.assert_not_called() + send_telegram.assert_not_called() + def test_github_api_outage_is_reported_as_unavailable_when_non_failing(self) -> None: with patch.dict( os.environ, @@ -352,6 +374,8 @@ def test_repository_runs_fallback_finds_recent_runtime_success(self) -> None: def fake_github_request(url: str, token: str) -> dict[str, object]: requested_urls.append(url) self.assertEqual(token, "token-1") + if url.endswith("/actions/workflows/main.yml"): + return {"state": "active", "path": ".github/workflows/main.yml"} if "/actions/workflows/main.yml/runs?" in url: return {"workflow_runs": []} if "/actions/runs?" in url: From eb7dbed5a03e6098d3dede960e54cbe0d8c6ed36 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:24:52 +0800 Subject: [PATCH 45/47] feat(execution): pin QPK #657 and consult T2/T3 on UNKNOWN deny (N13) (#363) Pin QuantPlatformKit to #657 (155d74a6) and consult pure T2/T3 guards on Binance SUBMISSION_UNKNOWN deny paths. Local raise still always wins (zero behavior change on allow / reconcile-success). FILLED_ACCOUNTING_PENDING untouched (B13-b G3). --- application/cycle_service.py | 3 ++ application/execution_kernel_adapter.py | 49 +++++++++++++++++++++++++ pyproject.toml | 4 +- qsl.toml | 2 +- runtime_support.py | 7 ++++ tests/test_execution_kernel_adapter.py | 40 ++++++++++++++++++++ uv.lock | 8 ++-- 7 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 application/execution_kernel_adapter.py create mode 100644 tests/test_execution_kernel_adapter.py diff --git a/application/cycle_service.py b/application/cycle_service.py index 05746ca2..70d3a11d 100644 --- a/application/cycle_service.py +++ b/application/cycle_service.py @@ -15,6 +15,7 @@ try_record_platform_execution, ) from application.execution_receipt_adapter import attach_execution_receipt_from_report +from application.execution_kernel_adapter import consult_t2_unknown_new_cycle from application.portfolio_service import EARN_FORWARD_REASON_CODES from runtime_logging import RuntimeLogContext, emit_runtime_log from runtime_support import ( @@ -231,6 +232,8 @@ def execute_strategy_cycle( reconcile_pending_funding_submission(runtime) submission_state = state.get("order_submission", {}).get("state", "RESERVED") if submission_state == "SUBMISSION_UNKNOWN": + # N13: redundant T2 consult; local raise always wins (no behavior change). + consult_t2_unknown_new_cycle() raise ExecutionIntegrityError("order_reconciliation_uncertain") if submission_state == "FILLED_ACCOUNTING_PENDING": raise ExecutionIntegrityError("filled_order_accounting_unverifiable") diff --git a/application/execution_kernel_adapter.py b/application/execution_kernel_adapter.py new file mode 100644 index 00000000..4d181f2f --- /dev/null +++ b/application/execution_kernel_adapter.py @@ -0,0 +1,49 @@ +"""Thin adapter: map Binance SUBMISSION_UNKNOWN deny paths onto QPK execution_kernel T2/T3. + +N13: consult-only / redundant fail-closed. Local raise still always wins. +Does not own claims or change allow / reconcile-success paths. +Does not touch FILLED_ACCOUNTING_PENDING (B13-b G3; BN-only). +""" + +from __future__ import annotations + +from quant_platform_kit.execution_kernel import ( + ExecutionGuardDecision, + SubmissionCertainty, + SubmissionRetrySnapshot, + SubmissionUnknownSnapshot, + deny_blind_retry_after_uncertain_transport, + deny_new_cycle_when_submission_unknown, +) + + +def consult_t2_unknown_new_cycle() -> ExecutionGuardDecision: + """Consult T2 when cycle entry finds SUBMISSION_UNKNOWN and will raise. + + Caller must still raise ExecutionIntegrityError("order_reconciliation_uncertain"). + """ + return deny_new_cycle_when_submission_unknown( + SubmissionUnknownSnapshot( + submission_certainty=SubmissionCertainty.UNKNOWN, + requesting_new_cycle_submit=True, + ) + ) + + +def consult_t3_deny_blind_retry( + *, + transport_uncertain: bool = True, + reconciled: bool = False, +) -> ExecutionGuardDecision: + """Consult T3 on paths that already refuse blind retry after uncertain transport. + + Caller must still raise OrderReconciliationError("order_reconciliation_uncertain"). + Do not call on reconcile-success return paths. + """ + return deny_blind_retry_after_uncertain_transport( + SubmissionRetrySnapshot( + transport_uncertain=bool(transport_uncertain), + reconciled=bool(reconciled), + requesting_blind_retry=True, + ) + ) diff --git a/pyproject.toml b/pyproject.toml index d99245e5..8ad4d9e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "0.1.0" description = "QuantStrategyLab platform layer for Binance exchange." requires-python = ">=3.11" dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@8e8ec51884bf8abb0a7ca699fc2da39fad134f89", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@155d74a615ccdebf7223787939c2dc7151d75efd", "crypto-strategies @ git+https://github.com/QuantStrategyLab/CryptoStrategies.git@7fcca8ee0280b3e66245eb673caadda8b8b3b9a3", "python-binance", "pandas", @@ -23,7 +23,7 @@ test = [ [tool.uv] override-dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@8e8ec51884bf8abb0a7ca699fc2da39fad134f89", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@155d74a615ccdebf7223787939c2dc7151d75efd", ] [tool.ruff] diff --git a/qsl.toml b/qsl.toml index d2c29498..06cb3c23 100644 --- a/qsl.toml +++ b/qsl.toml @@ -9,7 +9,7 @@ expires_at = "2026-09-30" next_action = "keep uv.lock current and maintain QPK/CryptoStrategies pin consistency" [qsl.requires] -quant_platform_kit = "8e8ec51884bf8abb0a7ca699fc2da39fad134f89" +quant_platform_kit = "155d74a615ccdebf7223787939c2dc7151d75efd" crypto_strategies = "7fcca8ee0280b3e66245eb673caadda8b8b3b9a3" [qsl.compat] diff --git a/runtime_support.py b/runtime_support.py index 8d17fcf1..1054146e 100644 --- a/runtime_support.py +++ b/runtime_support.py @@ -13,6 +13,7 @@ import requests from quant_platform_kit.common.runtime_reports import build_runtime_report_base +from application.execution_kernel_adapter import consult_t3_deny_blind_retry from application.execution_receipt_adapter import ( record_order_failure, record_order_response, @@ -1210,6 +1211,8 @@ def runtime_call_client(runtime, report, *, method_name, payload, effect_type, submission_status = submission_record["state"] if submission_status == _ORDER_SUBMISSION_UNKNOWN: if "method_name" in submission_record or is_earn_call: + # N13: redundant T3 consult; local raise always wins (no behavior change). + consult_t3_deny_blind_retry(transport_uncertain=True, reconciled=False) raise OrderReconciliationError("order_reconciliation_uncertain") from None current_payload, current_identity_sha256 = _ensure_order_logical_identity(runtime, method_name, payload) if current_identity_sha256 != submission_record["identity_sha256"]: @@ -1300,6 +1303,8 @@ def runtime_call_client(runtime, report, *, method_name, payload, effect_type, payload={"payload": dict(client_payload), "reason": "order_reconciliation_uncertain", "retries": 0}, executed=False, ) + # N13: redundant T3 consult; local raise always wins (no behavior change). + consult_t3_deny_blind_retry(transport_uncertain=True, reconciled=False) raise OrderReconciliationError("order_reconciliation_uncertain") from None if not _is_confirmed_earn_success(method_name, response): record_side_effect( @@ -1310,6 +1315,8 @@ def runtime_call_client(runtime, report, *, method_name, payload, effect_type, payload={"payload": dict(client_payload), "reason": "order_reconciliation_uncertain", "retries": 0}, executed=False, ) + # N13: redundant T3 consult; local raise always wins (no behavior change). + consult_t3_deny_blind_retry(transport_uncertain=True, reconciled=False) raise OrderReconciliationError("order_reconciliation_uncertain") from None record_side_effect( runtime, diff --git a/tests/test_execution_kernel_adapter.py b/tests/test_execution_kernel_adapter.py new file mode 100644 index 00000000..df133fcf --- /dev/null +++ b/tests/test_execution_kernel_adapter.py @@ -0,0 +1,40 @@ +"""N13: Binance adapter around QPK execution_kernel T2/T3 (UNKNOWN deny paths).""" + +from __future__ import annotations + +import unittest + +from application.execution_kernel_adapter import ( + consult_t2_unknown_new_cycle, + consult_t3_deny_blind_retry, +) + + +class ExecutionKernelAdapterTests(unittest.TestCase): + def test_t2_unknown_new_cycle_denies(self): + decision = consult_t2_unknown_new_cycle() + self.assertEqual(decision.decision, "deny") + self.assertEqual(decision.reason_code, "submission_uncertain") + self.assertFalse(decision.allowed) + + def test_t3_uncertain_unreconciled_blind_retry_denies(self): + decision = consult_t3_deny_blind_retry( + transport_uncertain=True, + reconciled=False, + ) + self.assertEqual(decision.decision, "deny") + self.assertEqual(decision.reason_code, "reconcile_required") + self.assertFalse(decision.allowed) + + def test_t3_reconciled_allows_kernel(self): + """If already reconciled, T3 allows; callers must not request blind retry here.""" + decision = consult_t3_deny_blind_retry( + transport_uncertain=True, + reconciled=True, + ) + self.assertEqual(decision.decision, "allow") + self.assertTrue(decision.allowed) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index c3ef1c05..1b8fb5d4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -17,7 +17,7 @@ resolution-markers = [ ] [manifest] -overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=8e8ec51884bf8abb0a7ca699fc2da39fad134f89" }] +overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=155d74a615ccdebf7223787939c2dc7151d75efd" }] [[package]] name = "aiohappyeyeballs" @@ -214,7 +214,7 @@ requires-dist = [ { name = "pandas" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8" }, { name = "python-binance" }, - { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=8e8ec51884bf8abb0a7ca699fc2da39fad134f89" }, + { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=155d74a615ccdebf7223787939c2dc7151d75efd" }, { name = "requests" }, { name = "ruff", marker = "extra == 'test'", specifier = ">=0.12" }, ] @@ -1658,7 +1658,7 @@ wheels = [ [[package]] name = "quant-platform-kit" version = "1.0.0" -source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=8e8ec51884bf8abb0a7ca699fc2da39fad134f89#8e8ec51884bf8abb0a7ca699fc2da39fad134f89" } +source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=155d74a615ccdebf7223787939c2dc7151d75efd#155d74a615ccdebf7223787939c2dc7151d75efd" } [[package]] name = "regex" From 9e5495f5e0a9cf28197b366434abc184923bab38 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:12:17 +0800 Subject: [PATCH 46/47] ops: read-only TG_TOKEN bot identity check (getMe id/username only) (#364) --- .github/workflows/notify-bot-identity.yml | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/notify-bot-identity.yml diff --git a/.github/workflows/notify-bot-identity.yml b/.github/workflows/notify-bot-identity.yml new file mode 100644 index 00000000..447a1fc7 --- /dev/null +++ b/.github/workflows/notify-bot-identity.yml @@ -0,0 +1,50 @@ +name: Notify Bot Identity (read-only) + +# Resolves the Telegram token the Binance runtime would use (main.yml deploy job: +# environment binance-runtime, secrets.TG_TOKEN) to its public bot id/username via +# getMe, and compares it with the unified QSL资产管家 bot (8334367951). +# Prints only bot id / username; never prints token or chat id; sends no message; +# does not re-enable main.yml or change any secret. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + identity: + name: TG_TOKEN bot identity (read-only) + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: binance-runtime + env: + TG_TOKEN: ${{ secrets.TG_TOKEN }} + TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + EXPECTED_BOT_ID: "8334367951" + steps: + - name: Resolve bot identity + run: | + python3 - <<'PY' + import json, os, urllib.request + expected = int(os.environ["EXPECTED_BOT_ID"]) + out = [] + for key in ("TG_TOKEN", "TELEGRAM_TOKEN"): + token = (os.environ.get(key) or "").strip() + if not token: + out.append({"env": key, "status": "absent"}) + continue + try: + with urllib.request.urlopen(f"https://api.telegram.org/bot{token}/getMe", timeout=15) as r: + body = json.loads(r.read().decode("utf-8")) + res = body.get("result") or {} + out.append({"env": key, "status": "ok" if body.get("ok") else "getme_not_ok", + "bot_id": res.get("id"), "bot_username": res.get("username"), + "same_bot_as_unified": res.get("id") == expected}) + except Exception as exc: # noqa: BLE001 + out.append({"env": key, "status": f"getme_failed:{type(exc).__name__}"}) + text = json.dumps({"runtime_token_env": "TG_TOKEN", "expected_bot_id": expected, "tokens": out}, + ensure_ascii=False, indent=2) + print(text) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as fh: + fh.write("## Binance notify bot identity\n\n```json\n" + text + "\n```\n") + PY From 0d55203a5d60d2beb6b4648f70875c6fc9563b37 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:15:18 +0800 Subject: [PATCH 47/47] ops: bot identity check reads repo-level TG_TOKEN without binance-runtime env (#365) Co-authored-by: Pigbibi --- .github/workflows/notify-bot-identity.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/notify-bot-identity.yml b/.github/workflows/notify-bot-identity.yml index 447a1fc7..d7a6104c 100644 --- a/.github/workflows/notify-bot-identity.yml +++ b/.github/workflows/notify-bot-identity.yml @@ -1,7 +1,8 @@ name: Notify Bot Identity (read-only) # Resolves the Telegram token the Binance runtime would use (main.yml deploy job: -# environment binance-runtime, secrets.TG_TOKEN) to its public bot id/username via +# secrets.TG_TOKEN, a repo-level secret; binance-runtime only holds other secrets +# and is restricted to runtime-production, so this job does not bind it) to its public bot id/username via # getMe, and compares it with the unified QSL资产管家 bot (8334367951). # Prints only bot id / username; never prints token or chat id; sends no message; # does not re-enable main.yml or change any secret. @@ -16,7 +17,6 @@ jobs: name: TG_TOKEN bot identity (read-only) runs-on: ubuntu-latest timeout-minutes: 5 - environment: binance-runtime env: TG_TOKEN: ${{ secrets.TG_TOKEN }} TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}