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. 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/.github/workflows/main.yml b/.github/workflows/main.yml index 0f33f3e9..8341b274 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@f2597e8bfb25881242b13145cb5f42d06ba3476c with: source-id: binance.runtime-target-lifecycle target-id: ${{ steps.disabled-host.outputs.target_id }} diff --git a/.github/workflows/notify-bot-identity.yml b/.github/workflows/notify-bot-identity.yml new file mode 100644 index 00000000..d7a6104c --- /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: +# 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. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + identity: + name: TG_TOKEN bot identity (read-only) + runs-on: ubuntu-latest + timeout-minutes: 5 + 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 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/.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/.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/.github/workflows/runtime-target-lifecycle.yml b/.github/workflows/runtime-target-lifecycle.yml index 6bc813c8..63ba73b8 100644 --- a/.github/workflows/runtime-target-lifecycle.yml +++ b/.github/workflows/runtime-target-lifecycle.yml @@ -5,23 +5,52 @@ 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: python3 -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 target state (read-only) + 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 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 +80,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 +92,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 +103,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 +117,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,9 +145,37 @@ jobs: test -s "$RUNTIME_HEARTBEAT_OUTPUT_PATH" echo "path=$RUNTIME_HEARTBEAT_OUTPUT_PATH" >> "$GITHUB_OUTPUT" - - name: Publish read-only runtime execution evidence + - 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@bbacba04baccbca91e4ba59c4dcaf460847e29f7 + uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence@f2597e8bfb25881242b13145cb5f42d06ba3476c with: source-id: runtime-reports-binance report-platform: binance @@ -126,17 +184,20 @@ 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 }} 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 - uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle@bbacba04baccbca91e4ba59c4dcaf460847e29f7 + uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-target-lifecycle@f2597e8bfb25881242b13145cb5f42d06ba3476c with: source-id: binance.runtime-target-lifecycle target-id: ${{ steps.target.outputs.target_id }} 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/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..7a832378 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. @@ -26,6 +21,12 @@ It is an execution layer, not a strategy research repository. Strategy logic com - 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 999af653..85674585 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。 @@ -26,6 +21,12 @@ BinancePlatform 是 QuantStrategyLab 的Binance 加密货币执行平台。通 - 凭据必须放在 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/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. diff --git a/application/account_facts.py b/application/account_facts.py new file mode 100644 index 00000000..47d28d35 --- /dev/null +++ b/application/account_facts.py @@ -0,0 +1,578 @@ +"""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 +import unicodedata +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 +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", +}) +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") +_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): + """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 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}") + + +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 _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, +) -> 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 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(): + 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_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") + account_scope_sha256 = calculate_broker_observation_sha256( + {"account_uid": str(uid)} + ) + 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): + 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_balance_row_invalid") + asset = row.get("asset") + if not isinstance(asset, str): + raise _fail("spot_asset_type_invalid") + if not _valid_asset(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")), + "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 _valid_asset(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) + 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") + + 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, + "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, + "completeness": "complete_for_scope", + "assets": asset_rows, + "wallet_valuation": wallet_valuation, + "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 not required.issubset(payload) + or set(payload) - required - {"wallet_valuation", "provider_product_type"} + ): + 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") + 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") + 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 _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")] + 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") + 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/application/cycle_service.py b/application/cycle_service.py index c2b55d07..41e40de3 100644 --- a/application/cycle_service.py +++ b/application/cycle_service.py @@ -17,6 +17,7 @@ resolve_lifecycle_stream_id, ) 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 ( @@ -251,6 +252,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/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/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/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_receipt_reader_candidate.md b/docs/interval_receipt_reader_candidate.md new file mode 100644 index 00000000..619fdcd6 --- /dev/null +++ b/docs/interval_receipt_reader_candidate.md @@ -0,0 +1,256 @@ +# 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 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 + +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 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. + +## 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 +- 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/docs/interval_source_receipt_candidate.md b/docs/interval_source_receipt_candidate.md new file mode 100644 index 00000000..95527a22 --- /dev/null +++ b/docs/interval_source_receipt_candidate.md @@ -0,0 +1,261 @@ +# 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. 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 +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. + +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. + +## 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, + 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` + +## 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..3b35661e 100644 --- a/main.py +++ b/main.py @@ -101,6 +101,15 @@ 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, + get_active_run_context, + reset_run_context, + resolve_strategy_runtime, + 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, @@ -256,8 +265,14 @@ 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. + + 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 global TREND_POOL_SIZE @@ -265,12 +280,21 @@ 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) + ) + 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 + STRATEGY_RUNTIME = activated + return activated class BalanceFetchError(RuntimeError): @@ -355,7 +379,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 ) @@ -452,7 +477,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 +487,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 +497,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 +506,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 +516,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, ) @@ -526,10 +551,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, ) @@ -650,9 +676,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( @@ -785,7 +812,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, ) @@ -833,28 +860,42 @@ 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, 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.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 -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, @@ -944,14 +985,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, @@ -1264,7 +1306,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()), ) @@ -1311,9 +1353,18 @@ 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 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, + ) + runtime.run_context = ctx + token = bind_run_context(ctx) try: return execute_strategy_cycle( runtime, @@ -1342,7 +1393,7 @@ def execute_cycle(runtime): traceback_module=traceback, ) finally: - TREND_UNIVERSE = previous_trend_universe + reset_run_context(token) def main(): 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/run_context.py b/run_context.py new file mode 100644 index 00000000..737a0acd --- /dev/null +++ b/run_context.py @@ -0,0 +1,91 @@ +"""Per-cycle RunContext for BinancePlatform (B09). + +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. +""" + +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 + + +_ACTIVE_RUN_CONTEXT: ContextVar[Optional[RunContext]] = ContextVar( + "binance_active_run_context", + default=None, +) + + +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) + + +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]] | 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 + + +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) + + +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: + 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 + + +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_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/runtime_support.py b/runtime_support.py index 2a5a90cf..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, @@ -310,6 +311,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 = "" @@ -319,6 +321,8 @@ 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 + strategy_runtime: Any = None # B09 PR-2: activated handle; cycle mounts onto RunContext def __post_init__(self): if self.now_utc is None: @@ -546,6 +550,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 +751,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 +903,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 @@ -1180,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"]: @@ -1270,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( @@ -1280,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/scripts/diagnose_binance_account_facts_receiver.py b/scripts/diagnose_binance_account_facts_receiver.py new file mode 100644 index 00000000..f9424d2b --- /dev/null +++ b/scripts/diagnose_binance_account_facts_receiver.py @@ -0,0 +1,175 @@ +#!/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" +_USER_AGENT = "QSL-AccountFacts-Readiness/1.0" + +_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", + "User-Agent": _USER_AGENT, + }, + 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/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/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()) diff --git a/scripts/publish_binance_account_facts.py b/scripts/publish_binance_account_facts.py new file mode 100644 index 00000000..e07e08bb --- /dev/null +++ b/scripts/publish_binance_account_facts.py @@ -0,0 +1,151 @@ +#!/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" +_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", + (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): + """A fixed non-sensitive publisher stop code.""" + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, new_url): + return None + + +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): + 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 f"account_facts_publish_http_{status}_body_invalid_json" + + +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", + "User-Agent": _USER_AGENT, + }, + 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_ack_invalid") + ack = json.loads(raw_ack.decode("utf-8")) + except PublishError: + raise + 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"] + + +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..9591de6c --- /dev/null +++ b/scripts/read_binance_account_facts.py @@ -0,0 +1,1073 @@ +#!/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" +LEGACY_RUNTIME_WORKFLOW_SHA = "9cfcf0531d1ea176e6f26590cf15edbd31bd6567" +APPROVED_APPLICATION_SHA = "8cb56617115fa45028e34d788e71884b6a303d77" +RUNTIME_BRANCH = "runtime-production" +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") + + +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, + 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", + 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 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") != 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 + ): + 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 _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 _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: + 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 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 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] + 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. + _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 + ): + 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 == "direct_read": + verify_direct_reader_source_from_env(env) + elif 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_terminal_source_from_env(env) + else: + raise _stop("source_mode_invalid") + + +def verify_direct_reader_source_from_env(env: Mapping[str, str]) -> None: + """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 + 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, + 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") + 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 + # 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 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") != 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) + 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) + _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, + ) + 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 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") + _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(): + 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 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 + 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, *, 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") + try: + from quant_platform_kit.common.runtime_target import resolve_runtime_target_from_env + + 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 and not allow_dry_run_only) + 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 _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 "") + 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 _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 _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"): + 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) + verify_current_source_from_env(env) + + validated_payload = _collect_bound_account_facts( + binding=binding, expected_scope=expected_scope, + reader_revision=reader_revision, env=env, + ) + verify_current_source_from_env(env) + _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", "")) + 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_terminal_source_from_env(env) + 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", + 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("--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"), + ) + 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( + 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) + return 1 + except Exception: + print("account_facts_unexpected_failure", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) 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 new file mode 100644 index 00000000..5b9609bb --- /dev/null +++ b/scripts/runtime_scheduler_probe.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Emit a bounded read-only observation of the reviewed runtime scheduler.""" + +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 + + +_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(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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/runtime_target_lifecycle_monitoring.py b/scripts/runtime_target_lifecycle_monitoring.py index 519c508e..9cdcfd7e 100644 --- a/scripts/runtime_target_lifecycle_monitoring.py +++ b/scripts/runtime_target_lifecycle_monitoring.py @@ -5,9 +5,13 @@ import json import os +import re +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: @@ -41,6 +45,65 @@ def _execution_check(status: str) -> str: }.get(status, "unavailable") +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") + 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 _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, @@ -71,16 +134,22 @@ 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") - 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(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") if output: with open(output, "a", encoding="utf-8") as handle: diff --git a/scripts/runtime_workflow_heartbeat.py b/scripts/runtime_workflow_heartbeat.py index f65b737b..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 @@ -445,7 +458,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) @@ -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/fixtures/binance_account_facts.v1.synthetic.json b/tests/fixtures/binance_account_facts.v1.synthetic.json new file mode 100644 index 00000000..039ff6a0 --- /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","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 new file mode 100644 index 00000000..496b844e --- /dev/null +++ b/tests/test_binance_account_facts.py @@ -0,0 +1,1945 @@ +from __future__ import annotations + +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 + +import pytest + +from application.account_facts import ( + AccountFactsUnavailable, + ReadOnlyBinanceClient, + build_source_binding_id, + collect_account_facts, + validate_account_facts_payload, +) + + +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) + + +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", + "accountType": "SPOT", + "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 _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 + + 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, 4)]) + 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["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"] + 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) + + +@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) + 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, 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") + + +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() + checkout = workflow.index(" - name: Checkout trusted default-branch reader source") + setup_uv = workflow.index(" - name: Set up uv") + 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] + 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 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/runtime-production'" in workflow + assert "options: [direct_read, legacy_terminal]" in workflow + assert "expected_ref=refs/heads/runtime-production" 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/runtime-production", + "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/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_protected_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_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") == ( + "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 + + 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 · 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 · strategy", + "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 _current_terminal_run_api(url, _token, mutation=None): + from urllib.parse import urlparse + + parsed = urlparse(url) + 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 · 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": 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 · strategy", + "path": source_path, + "event": "workflow_dispatch", "status": "completed", "conclusion": "success", + "head_branch": "runtime-production", "head_sha": source_sha, + "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" + 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"), + ("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"), + ], +) +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 + + 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(): + 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",)] + + +@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": "A\u200b"}]}, "spot_asset_format_invalid"), + ({"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",)] + + +@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): + 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 [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"]) + + +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") + + +@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_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 + + 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 + + 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 · strategy", + "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 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 + + 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) + + +@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 + + 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() + + 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() + + 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={ + "BINANCE_ACCOUNT_FACTS_ENABLED": "true", + "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): + 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", + }, + ) + + +@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", "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, reason +): + 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) == 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] + + +@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] + + +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() + + 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) + + +@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 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") diff --git a/tests/test_cycle_service.py b/tests/test_cycle_service.py index 747a2471..ac2570c3 100644 --- a/tests/test_cycle_service.py +++ b/tests/test_cycle_service.py @@ -1454,3 +1454,153 @@ 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)) + 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 = [] + # 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): + 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)) + 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')] 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/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_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() 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() 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_run_context.py b/tests/test_run_context.py new file mode 100644 index 00000000..d8ac26f6 --- /dev/null +++ b/tests/test_run_context.py @@ -0,0 +1,347 @@ +"""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 +from run_context import ( + RunContext, + RunContextError, + bind_run_context, + get_active_run_context, + reset_run_context, + resolve_strategy_runtime, + 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) + 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()) + 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()) + + 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, 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") + 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): + 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() + + 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_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 + 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(), + strategy_runtime=SimpleNamespace(profile="seed"), + ) + 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, prior) # no module leakage + 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 + + 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 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): + 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() 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, 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_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..1399d4b8 --- /dev/null +++ b/tests/test_runtime_scheduler_probe.py @@ -0,0 +1,77 @@ +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" + 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(["--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']}"], + ) + + 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") + + 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_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_runtime_target_lifecycle_monitoring.py b/tests/test_runtime_target_lifecycle_monitoring.py index b978c2aa..55d42c56 100644 --- a/tests/test_runtime_target_lifecycle_monitoring.py +++ b/tests/test_runtime_target_lifecycle_monitoring.py @@ -10,27 +10,114 @@ class RuntimeTargetLifecycleMonitoringTests(unittest.TestCase): - def test_report_observation_is_forwarded_without_changing_its_time(self): - deployment = { - "runtime_enabled": False, - "scheduler_state": "unknown", + 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" + 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()) + 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": "2026-08-30T02:00:00Z", + "observed_at": observed_at, + }) + + 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": "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": deployment}), encoding="utf-8") + 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"]), deployment) + 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": {"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"])["scheduler_state"], "paused") + self.assertEqual(values["execution_heartbeat"], "attention") + + 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", + ) + 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: @@ -40,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): diff --git a/tests/test_runtime_workflow_heartbeat.py b/tests/test_runtime_workflow_heartbeat.py index 264128bb..bff5b748 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, @@ -108,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, @@ -272,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: @@ -295,6 +399,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: 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() 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", diff --git a/uv.lock b/uv.lock index 164f779c..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" }, ] @@ -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]] @@ -1617,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" @@ -1817,11 +1858,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]] @@ -1938,14 +1979,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]]